複数ストアの使い分け:Zustand でドメインごとに状態を分離する方法

現代の React アプリケーション開発では、単一の巨大なストアで全ての状態を管理することが、保守性やスケーラビリティの観点から課題となっています。特に大規模なアプリケーションや複数チームでの開発において、ドメインごとに適切に状態を分離することは、コードの可読性と開発効率を大幅に向上させます。
Zustand の柔軟性を活かせば、ドメイン駆動設計(DDD)の原則に基づいた状態管理アーキテクチャを構築できます。本記事では、実際の開発現場で活用できる複数ストアの設計パターンと実装手法をご紹介いたします。
背景
単一ストアの限界とスケーラビリティ課題
従来の状態管理では、アプリケーション全体で単一のストアを使用することが一般的でした。しかし、アプリケーションが成長するにつれて、以下のような課題が顕著に現れます。
単一ストアでの典型的な問題
typescript// ❌ 問題:全てが一つのストアに集約されている
interface MonolithicState {
// ユーザー関連
user: User | null;
isAuthenticated: boolean;
authLoading: boolean;
// 商品関連
products: Product[];
selectedProduct: Product | null;
productFilters: ProductFilters;
productLoading: boolean;
// カート関連
cartItems: CartItem[];
cartTotal: number;
cartLoading: boolean;
// 注文関連
orders: Order[];
currentOrder: Order | null;
orderHistory: Order[];
orderLoading: boolean;
// UI状態
sidebarOpen: boolean;
modalOpen: boolean;
notifications: Notification[];
// ... さらに数十の状態が続く
}
このようなモノリシックな構造では、以下の問題が発生します。
# | 問題点 | 具体的な影響 |
---|---|---|
1 | 責任範囲の曖昧性 | どのコンポーネントがどの状態を変更するか不明 |
2 | テストの複雑化 | 単一機能のテストでも全状態のモックが必要 |
3 | パフォーマンスの劣化 | 無関係な状態変更でも全体が再レンダリング |
4 | 並行開発の困難 | 複数チームでの同時開発時にコンフリクト頻発 |
実際のエラー例
typescript// TypeError: Cannot read property 'id' of undefined
const ProductComponent = () => {
const { products, user } = useMonolithicStore();
// userが未ロード時にproductsの処理でエラー発生
const userSpecificProducts = products.filter(
(product) => product.ownerId === user.id // ❌ user が null の可能性
);
return <div>{/* ... */}</div>;
};
ドメイン駆動設計(DDD)と状態管理の関係
ドメイン駆動設計の原則を状態管理に適用することで、より保守性の高いアーキテクチャを構築できます。
境界付きコンテキストによる分離
typescript// ✅ 改善:ドメインごとの境界付きコンテキスト
// ユーザードメイン
interface UserContext {
user: User | null;
isAuthenticated: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => void;
}
// 商品ドメイン
interface ProductContext {
products: Product[];
selectedProduct: Product | null;
filters: ProductFilters;
loadProducts: (filters?: ProductFilters) => Promise<void>;
selectProduct: (id: string) => void;
}
// カートドメイン
interface CartContext {
items: CartItem[];
total: number;
addItem: (productId: string, quantity: number) => void;
removeItem: (itemId: string) => void;
clear: () => void;
}
アグリゲート単位での状態管理
typescript// 注文アグリゲート
interface OrderAggregate {
id: string;
customerId: string;
items: OrderItem[];
status: OrderStatus;
total: number;
createdAt: Date;
// ドメインロジックをカプセル化
addItem(item: OrderItem): void;
removeItem(itemId: string): void;
calculateTotal(): number;
canCancel(): boolean;
}
const useOrderStore = create<OrderState & OrderActions>(
(set, get) => ({
orders: [],
currentOrder: null,
createOrder: async (customerId: string) => {
const order: OrderAggregate = {
id: crypto.randomUUID(),
customerId,
items: [],
status: 'pending',
total: 0,
createdAt: new Date(),
addItem(item: OrderItem) {
this.items.push(item);
this.total = this.calculateTotal();
},
removeItem(itemId: string) {
this.items = this.items.filter(
(item) => item.id !== itemId
);
this.total = this.calculateTotal();
},
calculateTotal() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
canCancel() {
return (
this.status === 'pending' ||
this.status === 'confirmed'
);
},
};
set((state) => ({
currentOrder: order,
orders: [...state.orders, order],
}));
},
})
);
マイクロフロントエンド時代の状態分離ニーズ
マイクロフロントエンドアーキテクチャでは、各チームが独立して開発・デプロイできる状態管理が必要です。
チーム境界に基づく状態分離
typescript// チームA: 商品カタログ担当
export const useProductCatalogStore =
create<ProductCatalogState>((set) => ({
products: [],
categories: [],
filters: defaultFilters,
loadProducts: async (categoryId?: string) => {
try {
const response = await catalogAPI.getProducts(
categoryId
);
set({ products: response.data });
} catch (error) {
console.error('Failed to load products:', error);
throw new Error('CATALOG_LOAD_FAILED');
}
},
}));
// チームB: ユーザー管理担当
export const useUserManagementStore =
create<UserManagementState>((set) => ({
users: [],
currentUser: null,
loadUser: async (userId: string) => {
try {
const response = await userAPI.getUser(userId);
set({ currentUser: response.data });
} catch (error) {
console.error('Failed to load user:', error);
throw new Error('USER_LOAD_FAILED');
}
},
}));
// チームC: 決済処理担当
export const usePaymentStore = create<PaymentState>(
(set) => ({
paymentMethods: [],
currentTransaction: null,
processPayment: async (paymentData: PaymentData) => {
try {
const response = await paymentAPI.processPayment(
paymentData
);
set({ currentTransaction: response.data });
} catch (error) {
console.error('Payment failed:', error);
throw new Error('PAYMENT_PROCESSING_FAILED');
}
},
})
);
課題
境界付きコンテキストの定義と実装
適切な境界付きコンテキストの定義は、複数ストア設計の最も重要な要素です。
境界定義の課題
typescript// ❌ 問題:境界が曖昧な設計
interface AmbiguousContext {
// ユーザー情報とカート情報が混在
user: User;
userCart: CartItem[];
userOrders: Order[];
userPreferences: UserPreferences;
// どのドメインの責任か不明
updateUserCart: (items: CartItem[]) => void;
addToUserCart: (productId: string) => void;
}
実際のエラーケース
typescript// ReferenceError: Cannot access 'userStore' before initialization
const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: async (productId: string, quantity: number) => {
// ❌ 循環依存エラー
const user = useUserStore.getState().user;
if (!user) {
throw new Error('USER_NOT_AUTHENTICATED');
}
// カートストアがユーザーストアに依存
const updatedItems = [
...get().items,
{ productId, quantity, userId: user.id },
];
set({ items: updatedItems });
},
}));
const useUserStore = create<UserState>((set) => ({
user: null,
logout: () => {
// ❌ ユーザーストアがカートストアに依存
useCartStore.getState().clearCart();
set({ user: null });
},
}));
適切な境界定義の実装
typescript// ✅ 改善:明確な責任分離
// ユーザードメイン:認証とプロファイル管理
const useUserStore = create<UserState & UserActions>(
(set) => ({
user: null,
isAuthenticated: false,
login: async (credentials: LoginCredentials) => {
const response = await authAPI.login(credentials);
set({
user: response.user,
isAuthenticated: true,
});
// イベント発行で他のドメインに通知
eventBus.emit('user:authenticated', response.user);
},
logout: async () => {
await authAPI.logout();
set({ user: null, isAuthenticated: false });
// イベント発行で他のドメインに通知
eventBus.emit('user:logout');
},
})
);
// カートドメイン:商品の一時保存
const useCartStore = create<CartState & CartActions>(
(set, get) => ({
items: [],
addItem: (productId: string, quantity: number) => {
const newItem: CartItem = {
id: crypto.randomUUID(),
productId,
quantity,
addedAt: new Date(),
};
set((state) => ({
items: [...state.items, newItem],
}));
},
clearCart: () => {
set({ items: [] });
},
})
);
// イベントベースの連携
eventBus.on('user:logout', () => {
useCartStore.getState().clearCart();
});
ストア間の依存関係管理
複数ストアを使用する際の最大の課題は、ストア間の依存関係を適切に管理することです。
循環依存の問題
typescript// ❌ Error: Uncaught ReferenceError: Cannot access 'useOrderStore' before initialization
const usePaymentStore = create<PaymentState>((set) => ({
currentPayment: null,
processPayment: async (
orderId: string,
paymentData: PaymentData
) => {
try {
const order = useOrderStore
.getState()
.orders.find((o) => o.id === orderId);
// 決済処理...
// 注文ストアの状態を更新しようとして循環依存エラー
useOrderStore
.getState()
.updateOrderStatus(orderId, 'paid');
} catch (error) {
throw new Error('PAYMENT_PROCESS_FAILED');
}
},
}));
const useOrderStore = create<OrderState>((set) => ({
orders: [],
createOrder: async (orderData: OrderData) => {
const order = await orderAPI.create(orderData);
set((state) => ({ orders: [...state.orders, order] }));
// 決済ストアとの依存関係
usePaymentStore.getState().initializePayment(order.id);
},
}));
依存関係の可視化と管理
typescript// ✅ 改善:依存性注入パターンの活用
interface StoreRegistry {
userStore: ReturnType<typeof useUserStore>;
cartStore: ReturnType<typeof useCartStore>;
orderStore: ReturnType<typeof useOrderStore>;
paymentStore: ReturnType<typeof usePaymentStore>;
}
class DomainEventBus {
private handlers: Map<string, Function[]> = new Map();
emit(event: string, data?: any) {
const eventHandlers = this.handlers.get(event) || [];
eventHandlers.forEach((handler) => handler(data));
}
on(event: string, handler: Function) {
const existing = this.handlers.get(event) || [];
this.handlers.set(event, [...existing, handler]);
}
off(event: string, handler: Function) {
const existing = this.handlers.get(event) || [];
this.handlers.set(
event,
existing.filter((h) => h !== handler)
);
}
}
export const domainEventBus = new DomainEventBus();
ファサードパターンによる複雑性の隠蔽
typescript// ストア間の複雑な相互作用を隠蔽するファサード
class ECommerceAppFacade {
constructor(
private userStore: ReturnType<typeof useUserStore>,
private cartStore: ReturnType<typeof useCartStore>,
private orderStore: ReturnType<typeof useOrderStore>,
private paymentStore: ReturnType<typeof usePaymentStore>
) {
this.setupEventHandlers();
}
private setupEventHandlers() {
// ユーザーログアウト時の一連の処理
domainEventBus.on('user:logout', () => {
this.cartStore.getState().clearCart();
this.orderStore.getState().clearDraftOrders();
this.paymentStore.getState().resetPaymentState();
});
// 注文完了時の処理
domainEventBus.on(
'order:completed',
(orderId: string) => {
this.cartStore.getState().clearCart();
this.paymentStore
.getState()
.finalizePayment(orderId);
}
);
}
async checkoutFlow(cartItems: CartItem[]) {
try {
// 1. 注文作成
const order = await this.orderStore
.getState()
.createOrderFromCart(cartItems);
// 2. 決済初期化
await this.paymentStore
.getState()
.initializePayment(order.id);
// 3. 決済処理
const payment = await this.paymentStore
.getState()
.processPayment();
// 4. 注文確定
await this.orderStore
.getState()
.confirmOrder(order.id, payment.id);
// 5. イベント発行
domainEventBus.emit('order:completed', order.id);
return { order, payment };
} catch (error) {
console.error('Checkout failed:', error);
throw new Error('CHECKOUT_FLOW_FAILED');
}
}
}
状態の一貫性保持と同期問題
複数ストアで管理される状態間の一貫性を保つことは、特に重要な課題です。
データ整合性の問題
typescript// ❌ 問題:商品情報の不整合
const ProductListComponent = () => {
const { products } = useProductStore();
const { cartItems } = useCartStore();
return (
<div>
{cartItems.map((item) => {
// 商品ストアから最新情報を取得
const product = products.find(
(p) => p.id === item.productId
);
// ❌ 商品が削除された場合の不整合
if (!product) {
// TypeError: Cannot read property 'name' of undefined
return <div key={item.id}>商品情報なし</div>;
}
// ❌ 価格情報の不整合(カート追加時と現在で価格が変わった場合)
const priceChanged =
item.price !== product.currentPrice;
return (
<div key={item.id}>
{product.name} -{' '}
{priceChanged
? '価格変更あり'
: product.currentPrice}
</div>
);
})}
</div>
);
};
Eventual Consistency の実装
typescript// ✅ 改善:最終的一貫性を保つ仕組み
interface SyncManager {
syncProductPrices(): Promise<void>;
syncInventory(): Promise<void>;
resolveConflicts(): Promise<void>;
}
class ProductCartSyncManager implements SyncManager {
constructor(
private productStore: ReturnType<
typeof useProductStore
>,
private cartStore: ReturnType<typeof useCartStore>
) {
this.setupSyncHandlers();
}
private setupSyncHandlers() {
// 商品価格更新時のカート同期
domainEventBus.on(
'product:price-updated',
async (productId: string) => {
await this.syncCartItemPrice(productId);
}
);
// 商品削除時のカート同期
domainEventBus.on(
'product:deleted',
async (productId: string) => {
await this.removeFromCart(productId);
}
);
}
async syncProductPrices(): Promise<void> {
const cartItems = this.cartStore.getState().items;
const products = this.productStore.getState().products;
const syncPromises = cartItems.map(async (item) => {
const currentProduct = products.find(
(p) => p.id === item.productId
);
if (!currentProduct) {
// 商品が存在しない場合は カートから削除
await this.removeFromCart(item.productId);
return;
}
if (item.price !== currentProduct.currentPrice) {
// 価格が変更された場合の処理
await this.updateCartItemPrice(
item.id,
currentProduct.currentPrice
);
// ユーザーに通知
domainEventBus.emit('cart:price-changed', {
productId: item.productId,
oldPrice: item.price,
newPrice: currentProduct.currentPrice,
});
}
});
await Promise.all(syncPromises);
}
async syncInventory(): Promise<void> {
const cartItems = this.cartStore.getState().items;
const products = this.productStore.getState().products;
for (const item of cartItems) {
const product = products.find(
(p) => p.id === item.productId
);
if (!product || product.stock < item.quantity) {
// 在庫不足の場合は数量調整
const availableQuantity = product?.stock || 0;
if (availableQuantity === 0) {
await this.removeFromCart(item.productId);
} else {
await this.updateCartItemQuantity(
item.id,
availableQuantity
);
}
domainEventBus.emit('cart:inventory-adjusted', {
productId: item.productId,
requestedQuantity: item.quantity,
availableQuantity,
});
}
}
}
async resolveConflicts(): Promise<void> {
await Promise.all([
this.syncProductPrices(),
this.syncInventory(),
]);
}
private async syncCartItemPrice(
productId: string
): Promise<void> {
const cartItems = this.cartStore.getState().items;
const products = this.productStore.getState().products;
const product = products.find(
(p) => p.id === productId
);
const cartItem = cartItems.find(
(item) => item.productId === productId
);
if (
product &&
cartItem &&
cartItem.price !== product.currentPrice
) {
await this.updateCartItemPrice(
cartItem.id,
product.currentPrice
);
}
}
private async removeFromCart(
productId: string
): Promise<void> {
this.cartStore.getState().removeByProductId(productId);
}
private async updateCartItemPrice(
itemId: string,
newPrice: number
): Promise<void> {
this.cartStore
.getState()
.updateItemPrice(itemId, newPrice);
}
private async updateCartItemQuantity(
itemId: string,
newQuantity: number
): Promise<void> {
this.cartStore
.getState()
.updateItemQuantity(itemId, newQuantity);
}
}
競合状態の回避
typescript// 楽観的ロック実装
interface VersionedEntity {
id: string;
version: number;
lastModified: Date;
}
interface VersionedProduct
extends Product,
VersionedEntity {}
const useProductStore = create<
ProductState & ProductActions
>((set, get) => ({
products: [],
updateProduct: async (
id: string,
updates: Partial<Product>
) => {
const currentProduct = get().products.find(
(p) => p.id === id
) as VersionedProduct;
if (!currentProduct) {
throw new Error('PRODUCT_NOT_FOUND');
}
try {
const response = await productAPI.update(id, {
...updates,
version: currentProduct.version, // 現在のバージョンを送信
});
set((state) => ({
products: state.products.map((p) =>
p.id === id ? { ...p, ...response.data } : p
),
}));
domainEventBus.emit('product:updated', response.data);
} catch (error) {
if (error.response?.status === 409) {
// 楽観的ロック競合エラー
throw new Error('PRODUCT_VERSION_CONFLICT');
}
throw error;
}
},
}));
解決策
ドメイン別ストア設計原則
効果的な複数ストア設計には、明確な設計原則が必要です。
Single Responsibility Principle の適用
typescript// ✅ 原則1: 各ストアは単一の責任を持つ
// 認証専用ストア
const useAuthStore = create<AuthState & AuthActions>(
(set) => ({
user: null,
isAuthenticated: false,
loginAttempts: 0,
login: async (credentials: LoginCredentials) => {
try {
const response = await authAPI.login(credentials);
set({
user: response.user,
isAuthenticated: true,
loginAttempts: 0,
});
domainEventBus.emit(
'auth:login-success',
response.user
);
} catch (error) {
set((state) => ({
loginAttempts: state.loginAttempts + 1,
}));
domainEventBus.emit('auth:login-failed', error);
throw error;
}
},
})
);
// UI状態専用ストア
const useUIStore = create<UIState & UIActions>((set) => ({
sidebarOpen: false,
modals: {},
notifications: [],
openSidebar: () => set({ sidebarOpen: true }),
closeSidebar: () => set({ sidebarOpen: false }),
openModal: (modalId: string, props?: any) =>
set((state) => ({
modals: {
...state.modals,
[modalId]: { open: true, props },
},
})),
closeModal: (modalId: string) =>
set((state) => ({
modals: {
...state.modals,
[modalId]: { open: false, props: null },
},
})),
}));
Aggregate Root Pattern の活用
typescript// ✅ 原則2: アグリゲートルートによる整合性保証
class OrderAggregate {
constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[] = [],
private _status: OrderStatus = 'draft',
private _total: number = 0
) {}
get items(): ReadonlyArray<OrderItem> {
return this._items;
}
get status(): OrderStatus {
return this._status;
}
get total(): number {
return this._total;
}
addItem(
productId: string,
quantity: number,
price: number
): void {
if (this._status !== 'draft') {
throw new Error('ORDER_NOT_EDITABLE');
}
const existingItem = this._items.find(
(item) => item.productId === productId
);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this._items.push({
id: crypto.randomUUID(),
productId,
quantity,
price,
subtotal: price * quantity,
});
}
this.recalculateTotal();
}
removeItem(itemId: string): void {
if (this._status !== 'draft') {
throw new Error('ORDER_NOT_EDITABLE');
}
this._items = this._items.filter(
(item) => item.id !== itemId
);
this.recalculateTotal();
}
confirm(): void {
if (this._items.length === 0) {
throw new Error('ORDER_EMPTY');
}
if (this._status !== 'draft') {
throw new Error('ORDER_ALREADY_CONFIRMED');
}
this._status = 'confirmed';
}
private recalculateTotal(): void {
this._total = this._items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
}
const useOrderStore = create<OrderState & OrderActions>(
(set, get) => ({
orders: new Map<string, OrderAggregate>(),
currentOrderId: null,
createOrder: (customerId: string) => {
const orderId = crypto.randomUUID();
const order = new OrderAggregate(orderId, customerId);
set((state) => ({
orders: new Map(state.orders).set(orderId, order),
currentOrderId: orderId,
}));
return orderId;
},
addItemToOrder: (
orderId: string,
productId: string,
quantity: number,
price: number
) => {
const order = get().orders.get(orderId);
if (!order) {
throw new Error('ORDER_NOT_FOUND');
}
try {
order.addItem(productId, quantity, price);
// 変更を反映
set((state) => ({
orders: new Map(state.orders).set(orderId, order),
}));
domainEventBus.emit('order:item-added', {
orderId,
productId,
quantity,
total: order.total,
});
} catch (error) {
domainEventBus.emit('order:error', error);
throw error;
}
},
})
);
ストア間通信アーキテクチャ
ストア間の効果的な通信パターンを実装することで、疎結合を保ちながら協調動作を実現します。
イベントドリブンアーキテクチャ
typescript// ✅ イベントベースの通信システム
interface DomainEvent {
type: string;
payload: any;
timestamp: Date;
correlationId?: string;
}
class EventSourcing {
private events: DomainEvent[] = [];
private handlers: Map<string, Function[]> = new Map();
emit(
type: string,
payload: any,
correlationId?: string
): void {
const event: DomainEvent = {
type,
payload,
timestamp: new Date(),
correlationId,
};
this.events.push(event);
const eventHandlers = this.handlers.get(type) || [];
eventHandlers.forEach((handler) => {
try {
handler(event);
} catch (error) {
console.error(
`Event handler error for ${type}:`,
error
);
}
});
}
on(
type: string,
handler: (event: DomainEvent) => void
): void {
const existing = this.handlers.get(type) || [];
this.handlers.set(type, [...existing, handler]);
}
getEventHistory(correlationId?: string): DomainEvent[] {
if (correlationId) {
return this.events.filter(
(event) => event.correlationId === correlationId
);
}
return [...this.events];
}
replay(fromTimestamp: Date): void {
const eventsToReplay = this.events.filter(
(event) => event.timestamp >= fromTimestamp
);
eventsToReplay.forEach((event) => {
const handlers = this.handlers.get(event.type) || [];
handlers.forEach((handler) => handler(event));
});
}
}
export const eventSourcing = new EventSourcing();
Saga Pattern による複雑な業務フローの管理
typescript// 注文処理のSagaパターン実装
class OrderProcessSaga {
constructor(
private orderStore: ReturnType<typeof useOrderStore>,
private paymentStore: ReturnType<
typeof usePaymentStore
>,
private inventoryStore: ReturnType<
typeof useInventoryStore
>,
private notificationStore: ReturnType<
typeof useNotificationStore
>
) {
this.setupSagaHandlers();
}
private setupSagaHandlers(): void {
eventSourcing.on(
'order:created',
this.handleOrderCreated.bind(this)
);
eventSourcing.on(
'payment:completed',
this.handlePaymentCompleted.bind(this)
);
eventSourcing.on(
'payment:failed',
this.handlePaymentFailed.bind(this)
);
eventSourcing.on(
'inventory:reserved',
this.handleInventoryReserved.bind(this)
);
eventSourcing.on(
'inventory:reservation-failed',
this.handleInventoryReservationFailed.bind(this)
);
}
async startOrderProcess(
orderData: OrderData
): Promise<void> {
const correlationId = crypto.randomUUID();
try {
// Step 1: 注文作成
const orderId = await this.orderStore
.getState()
.createOrder(orderData);
eventSourcing.emit(
'order:created',
{ orderId, orderData },
correlationId
);
} catch (error) {
eventSourcing.emit(
'order:creation-failed',
{ error, orderData },
correlationId
);
throw error;
}
}
private async handleOrderCreated(
event: DomainEvent
): Promise<void> {
const { orderId, orderData } = event.payload;
try {
// Step 2: 在庫予約
await this.inventoryStore
.getState()
.reserveItems(orderData.items);
eventSourcing.emit(
'inventory:reserved',
{ orderId, items: orderData.items },
event.correlationId
);
} catch (error) {
eventSourcing.emit(
'inventory:reservation-failed',
{ orderId, error },
event.correlationId
);
}
}
private async handleInventoryReserved(
event: DomainEvent
): Promise<void> {
const { orderId } = event.payload;
try {
// Step 3: 決済処理
const order = this.orderStore
.getState()
.orders.get(orderId);
if (!order) {
throw new Error('ORDER_NOT_FOUND');
}
await this.paymentStore.getState().processPayment({
orderId,
amount: order.total,
});
eventSourcing.emit(
'payment:completed',
{ orderId },
event.correlationId
);
} catch (error) {
eventSourcing.emit(
'payment:failed',
{ orderId, error },
event.correlationId
);
}
}
private async handlePaymentCompleted(
event: DomainEvent
): Promise<void> {
const { orderId } = event.payload;
try {
// Step 4: 注文確定
await this.orderStore
.getState()
.confirmOrder(orderId);
// Step 5: 通知送信
await this.notificationStore
.getState()
.sendOrderConfirmation(orderId);
eventSourcing.emit(
'order:process-completed',
{ orderId },
event.correlationId
);
} catch (error) {
eventSourcing.emit(
'order:confirmation-failed',
{ orderId, error },
event.correlationId
);
}
}
private async handlePaymentFailed(
event: DomainEvent
): Promise<void> {
const { orderId } = event.payload;
try {
// 補償トランザクション: 在庫予約解除
const order = this.orderStore
.getState()
.orders.get(orderId);
if (order) {
await this.inventoryStore
.getState()
.releaseReservation(order.items);
}
// 注文キャンセル
await this.orderStore.getState().cancelOrder(orderId);
eventSourcing.emit(
'order:cancelled',
{ orderId, reason: 'payment-failed' },
event.correlationId
);
} catch (error) {
eventSourcing.emit(
'order:cancellation-failed',
{ orderId, error },
event.correlationId
);
}
}
private async handleInventoryReservationFailed(
event: DomainEvent
): Promise<void> {
const { orderId } = event.payload;
try {
// 補償トランザクション: 注文キャンセル
await this.orderStore.getState().cancelOrder(orderId);
eventSourcing.emit(
'order:cancelled',
{ orderId, reason: 'inventory-unavailable' },
event.correlationId
);
} catch (error) {
eventSourcing.emit(
'order:cancellation-failed',
{ orderId, error },
event.correlationId
);
}
}
}
依存性注入パターンの活用
依存性注入を使用してストア間の結合度を下げ、テスタビリティを向上させます。
依存性注入コンテナの実装
typescript// ✅ 依存性注入パターン
interface ServiceContainer {
register<T>(token: string, factory: () => T): void;
resolve<T>(token: string): T;
registerSingleton<T>(
token: string,
factory: () => T
): void;
}
class DIContainer implements ServiceContainer {
private services = new Map<string, any>();
private factories = new Map<string, () => any>();
private singletons = new Map<string, any>();
register<T>(token: string, factory: () => T): void {
this.factories.set(token, factory);
}
registerSingleton<T>(
token: string,
factory: () => T
): void {
this.factories.set(token, factory);
this.singletons.set(token, null);
}
resolve<T>(token: string): T {
if (this.singletons.has(token)) {
let instance = this.singletons.get(token);
if (!instance) {
instance = this.factories.get(token)!();
this.singletons.set(token, instance);
}
return instance;
}
const factory = this.factories.get(token);
if (!factory) {
throw new Error(`Service not registered: ${token}`);
}
return factory();
}
}
export const container = new DIContainer();
// ストアの登録
container.registerSingleton(
'userStore',
() => useUserStore
);
container.registerSingleton(
'cartStore',
() => useCartStore
);
container.registerSingleton(
'orderStore',
() => useOrderStore
);
container.registerSingleton(
'paymentStore',
() => usePaymentStore
);
// サービスの登録
container.register(
'orderProcessSaga',
() =>
new OrderProcessSaga(
container.resolve('orderStore'),
container.resolve('paymentStore'),
container.resolve('inventoryStore'),
container.resolve('notificationStore')
)
);
Provider Pattern による DI の React 統合
typescript// React Context と組み合わせた DI パターン
interface StoreProviderProps {
children: React.ReactNode;
container?: DIContainer;
}
const StoreContext = React.createContext<DIContainer | null>(null);
export const StoreProvider: React.FC<StoreProviderProps> = ({
children,
container: providedContainer
}) => {
const containerRef = useRef(providedContainer || new DIContainer());
useEffect(() => {
const container = containerRef.current;
// ストアの初期化
container.registerSingleton('userStore', () => useUserStore);
container.registerSingleton('cartStore', () => useCartStore);
container.registerSingleton('orderStore', () => useOrderStore);
// サガの初期化
const orderSaga = container.resolve('orderProcessSaga');
return () => {
// クリーンアップ処理
};
}, []);
return (
<StoreContext.Provider value={containerRef.current}>
{children}
</StoreContext.Provider>
);
};
// カスタムフック
export const useContainer = (): DIContainer => {
const container = useContext(StoreContext);
if (!container) {
throw new Error('useContainer must be used within StoreProvider');
}
return container;
};
export const useService = <T>(token: string): T => {
const container = useContainer();
return container.resolve<T>(token);
};
// 使用例
const CheckoutComponent: React.FC = () => {
const orderSaga = useService<OrderProcessSaga>('orderProcessSaga');
const orderStore = useService('orderStore');
const handleCheckout = async (orderData: OrderData) => {
try {
await orderSaga.startOrderProcess(orderData);
} catch (error) {
console.error('Checkout failed:', error);
}
};
return (
<button onClick={() => handleCheckout(/* orderData */)}>
注文する
</button>
);
};
具体例
E コマースサイトでのドメイン分離(商品、カート、ユーザー、注文)
実際の E コマースサイトでの複数ストア実装例をご紹介します。
商品カタログストア
typescriptinterface Product {
id: string;
name: string;
price: number;
description: string;
categoryId: string;
stock: number;
images: string[];
}
interface ProductState {
products: Product[];
categories: Category[];
filters: ProductFilters;
selectedProduct: Product | null;
loading: boolean;
error: string | null;
}
interface ProductActions {
loadProducts: (filters?: ProductFilters) => Promise<void>;
selectProduct: (id: string) => void;
updateFilters: (filters: Partial<ProductFilters>) => void;
clearSelection: () => void;
}
const useProductStore = create<
ProductState & ProductActions
>((set, get) => ({
products: [],
categories: [],
filters: {
categoryId: null,
priceRange: { min: 0, max: 10000 },
sortBy: 'name',
inStock: false,
},
selectedProduct: null,
loading: false,
error: null,
loadProducts: async (filters?: ProductFilters) => {
set({ loading: true, error: null });
try {
const currentFilters = filters || get().filters;
const response = await productAPI.getProducts(
currentFilters
);
set({
products: response.data,
loading: false,
});
domainEventBus.emit('products:loaded', response.data);
} catch (error) {
set({
loading: false,
error:
error instanceof Error
? error.message
: 'PRODUCTS_LOAD_FAILED',
});
throw error;
}
},
selectProduct: (id: string) => {
const product = get().products.find((p) => p.id === id);
set({ selectedProduct: product || null });
if (product) {
domainEventBus.emit('product:selected', product);
}
},
updateFilters: (filters: Partial<ProductFilters>) => {
const newFilters = { ...get().filters, ...filters };
set({ filters: newFilters });
// フィルター変更時に自動で商品を再読み込み
get().loadProducts(newFilters);
},
clearSelection: () => {
set({ selectedProduct: null });
},
}));
ショッピングカートストア
typescriptinterface CartItem {
id: string;
productId: string;
quantity: number;
price: number;
addedAt: Date;
}
interface CartState {
items: CartItem[];
total: number;
itemCount: number;
discount: number;
}
interface CartActions {
addItem: (
productId: string,
quantity: number
) => Promise<void>;
removeItem: (itemId: string) => void;
updateQuantity: (
itemId: string,
quantity: number
) => void;
clearCart: () => void;
applyDiscount: (code: string) => Promise<void>;
}
const useCartStore = create<CartState & CartActions>(
(set, get) => ({
items: [],
total: 0,
itemCount: 0,
discount: 0,
addItem: async (
productId: string,
quantity: number
) => {
try {
// 商品情報を取得(他ストアとの連携)
const productStore = useProductStore.getState();
const product = productStore.products.find(
(p) => p.id === productId
);
if (!product) {
throw new Error('PRODUCT_NOT_FOUND');
}
if (product.stock < quantity) {
throw new Error('INSUFFICIENT_STOCK');
}
const existingItem = get().items.find(
(item) => item.productId === productId
);
if (existingItem) {
const newQuantity =
existingItem.quantity + quantity;
get().updateQuantity(
existingItem.id,
newQuantity
);
} else {
const newItem: CartItem = {
id: crypto.randomUUID(),
productId,
quantity,
price: product.price,
addedAt: new Date(),
};
set((state) => {
const newItems = [...state.items, newItem];
return {
items: newItems,
total: calculateTotal(
newItems,
state.discount
),
itemCount: calculateItemCount(newItems),
};
});
}
domainEventBus.emit('cart:item-added', {
productId,
quantity,
});
} catch (error) {
domainEventBus.emit('cart:error', error);
throw error;
}
},
removeItem: (itemId: string) => {
set((state) => {
const newItems = state.items.filter(
(item) => item.id !== itemId
);
return {
items: newItems,
total: calculateTotal(newItems, state.discount),
itemCount: calculateItemCount(newItems),
};
});
domainEventBus.emit('cart:item-removed', { itemId });
},
updateQuantity: (itemId: string, quantity: number) => {
if (quantity <= 0) {
get().removeItem(itemId);
return;
}
set((state) => {
const newItems = state.items.map((item) =>
item.id === itemId ? { ...item, quantity } : item
);
return {
items: newItems,
total: calculateTotal(newItems, state.discount),
itemCount: calculateItemCount(newItems),
};
});
domainEventBus.emit('cart:quantity-updated', {
itemId,
quantity,
});
},
clearCart: () => {
set({
items: [],
total: 0,
itemCount: 0,
discount: 0,
});
domainEventBus.emit('cart:cleared');
},
applyDiscount: async (code: string) => {
try {
const response = await discountAPI.validateCode(
code,
get().items
);
const discount = response.discountAmount;
set((state) => ({
discount,
total: calculateTotal(state.items, discount),
}));
domainEventBus.emit('cart:discount-applied', {
code,
discount,
});
} catch (error) {
throw new Error('INVALID_DISCOUNT_CODE');
}
},
})
);
// ヘルパー関数
const calculateTotal = (
items: CartItem[],
discount: number
): number => {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return Math.max(0, subtotal - discount);
};
const calculateItemCount = (items: CartItem[]): number => {
return items.reduce(
(count, item) => count + item.quantity,
0
);
};
管理画面での機能別ストア設計
管理画面では、異なる権限レベルと機能領域に応じたストア分離が重要です。
ユーザー管理ストア
typescriptinterface AdminUser {
id: string;
email: string;
role: 'admin' | 'moderator' | 'viewer';
permissions: Permission[];
lastLogin: Date;
isActive: boolean;
}
interface UserManagementState {
users: AdminUser[];
selectedUser: AdminUser | null;
totalCount: number;
pagination: PaginationState;
filters: UserFilters;
loading: boolean;
}
interface UserManagementActions {
loadUsers: (
page?: number,
filters?: UserFilters
) => Promise<void>;
createUser: (
userData: CreateUserData
) => Promise<AdminUser>;
updateUser: (
id: string,
updates: Partial<AdminUser>
) => Promise<void>;
deleteUser: (id: string) => Promise<void>;
toggleUserStatus: (id: string) => Promise<void>;
}
const useUserManagementStore = create<
UserManagementState & UserManagementActions
>((set, get) => ({
users: [],
selectedUser: null,
totalCount: 0,
pagination: {
page: 1,
limit: 20,
hasNext: false,
hasPrev: false,
},
filters: { role: null, isActive: null, searchTerm: '' },
loading: false,
loadUsers: async (page = 1, filters?: UserFilters) => {
set({ loading: true });
try {
const currentFilters = filters || get().filters;
const response = await adminAPI.getUsers({
page,
limit: get().pagination.limit,
...currentFilters,
});
set({
users: response.data,
totalCount: response.total,
pagination: {
page,
limit: get().pagination.limit,
hasNext: response.hasNext,
hasPrev: response.hasPrev,
},
loading: false,
});
domainEventBus.emit(
'admin:users-loaded',
response.data
);
} catch (error) {
set({ loading: false });
domainEventBus.emit('admin:error', error);
throw error;
}
},
createUser: async (userData: CreateUserData) => {
try {
const response = await adminAPI.createUser(userData);
set((state) => ({
users: [response.data, ...state.users],
totalCount: state.totalCount + 1,
}));
domainEventBus.emit(
'admin:user-created',
response.data
);
return response.data;
} catch (error) {
domainEventBus.emit(
'admin:user-creation-failed',
error
);
throw error;
}
},
updateUser: async (
id: string,
updates: Partial<AdminUser>
) => {
try {
const response = await adminAPI.updateUser(
id,
updates
);
set((state) => ({
users: state.users.map((user) =>
user.id === id
? { ...user, ...response.data }
: user
),
selectedUser:
state.selectedUser?.id === id
? { ...state.selectedUser, ...response.data }
: state.selectedUser,
}));
domainEventBus.emit(
'admin:user-updated',
response.data
);
} catch (error) {
domainEventBus.emit(
'admin:user-update-failed',
error
);
throw error;
}
},
deleteUser: async (id: string) => {
try {
await adminAPI.deleteUser(id);
set((state) => ({
users: state.users.filter((user) => user.id !== id),
totalCount: state.totalCount - 1,
selectedUser:
state.selectedUser?.id === id
? null
: state.selectedUser,
}));
domainEventBus.emit('admin:user-deleted', { id });
} catch (error) {
domainEventBus.emit(
'admin:user-deletion-failed',
error
);
throw error;
}
},
toggleUserStatus: async (id: string) => {
const user = get().users.find((u) => u.id === id);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
await get().updateUser(id, {
isActive: !user.isActive,
});
},
}));
リアルタイム通信を含む複合アプリケーション
WebSocket を使用したリアルタイム機能との統合例です。
リアルタイム通知ストア
typescriptinterface Notification {
id: string;
type: 'info' | 'warning' | 'error' | 'success';
title: string;
message: string;
timestamp: Date;
read: boolean;
persistent: boolean;
}
interface NotificationState {
notifications: Notification[];
unreadCount: number;
connectionStatus:
| 'connected'
| 'disconnected'
| 'connecting';
}
interface NotificationActions {
connect: () => void;
disconnect: () => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
clearAll: () => void;
}
const useNotificationStore = create<
NotificationState & NotificationActions
>((set, get) => {
let socket: WebSocket | null = null;
return {
notifications: [],
unreadCount: 0,
connectionStatus: 'disconnected',
connect: () => {
if (socket && socket.readyState === WebSocket.OPEN) {
return;
}
set({ connectionStatus: 'connecting' });
socket = new WebSocket(process.env.REACT_APP_WS_URL!);
socket.onopen = () => {
set({ connectionStatus: 'connected' });
domainEventBus.emit('websocket:connected');
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'notification':
const notification: Notification = {
id: crypto.randomUUID(),
type: data.payload.type,
title: data.payload.title,
message: data.payload.message,
timestamp: new Date(),
read: false,
persistent:
data.payload.persistent || false,
};
set((state) => ({
notifications: [
notification,
...state.notifications,
],
unreadCount: state.unreadCount + 1,
}));
domainEventBus.emit(
'notification:received',
notification
);
break;
case 'order_status_update':
domainEventBus.emit(
'order:status-updated',
data.payload
);
break;
case 'inventory_update':
domainEventBus.emit(
'inventory:updated',
data.payload
);
break;
default:
console.warn(
'Unknown message type:',
data.type
);
}
} catch (error) {
console.error(
'Failed to parse WebSocket message:',
error
);
}
};
socket.onclose = () => {
set({ connectionStatus: 'disconnected' });
domainEventBus.emit('websocket:disconnected');
// 自動再接続
setTimeout(() => {
if (get().connectionStatus === 'disconnected') {
get().connect();
}
}, 5000);
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
domainEventBus.emit('websocket:error', error);
};
},
disconnect: () => {
if (socket) {
socket.close();
socket = null;
}
},
markAsRead: (id: string) => {
set((state) => ({
notifications: state.notifications.map(
(notification) =>
notification.id === id
? { ...notification, read: true }
: notification
),
unreadCount: Math.max(0, state.unreadCount - 1),
}));
},
markAllAsRead: () => {
set((state) => ({
notifications: state.notifications.map(
(notification) => ({
...notification,
read: true,
})
),
unreadCount: 0,
}));
},
removeNotification: (id: string) => {
set((state) => {
const notification = state.notifications.find(
(n) => n.id === id
);
return {
notifications: state.notifications.filter(
(n) => n.id !== id
),
unreadCount:
notification && !notification.read
? state.unreadCount - 1
: state.unreadCount,
};
});
},
clearAll: () => {
set({ notifications: [], unreadCount: 0 });
},
};
});
// 他のストアとの統合
const setupNotificationIntegration = () => {
// カート関連の通知
domainEventBus.on('cart:item-added', (data) => {
const notification: Notification = {
id: crypto.randomUUID(),
type: 'success',
title: '商品をカートに追加しました',
message: `商品が正常にカートに追加されました`,
timestamp: new Date(),
read: false,
persistent: false,
};
useNotificationStore
.getState()
.notifications.unshift(notification);
});
// 注文関連の通知
domainEventBus.on('order:completed', (orderId) => {
const notification: Notification = {
id: crypto.randomUUID(),
type: 'success',
title: '注文が完了しました',
message: `注文番号: ${orderId}`,
timestamp: new Date(),
read: false,
persistent: true,
};
useNotificationStore
.getState()
.notifications.unshift(notification);
});
};
// アプリケーション起動時に統合を設定
setupNotificationIntegration();
まとめ
本記事では、Zustand を使用した複数ストアによるドメイン分離の設計手法を包括的にご紹介しました。
主要なポイント
- 境界付きコンテキスト: DDD の原則に基づいた適切なドメイン分離により、保守性と拡張性を向上
- イベントドリブンアーキテクチャ: ストア間の疎結合を保ちながら効果的な連携を実現
- 依存性注入パターン: テスタビリティを向上させ、柔軟なアーキテクチャを構築
- Saga パターン: 複雑な業務フローを適切に管理し、データ整合性を保証
- リアルタイム統合: WebSocket などの外部システムとの統合も考慮した設計
実装時の注意点
- ストア分離の粒度は、チーム構成と開発フェーズに応じて調整する
- イベントベースの通信では、イベントの命名規則と型定義を統一する
- 状態の同期タイミングを明確に定義し、競合状態を避ける
- テスト可能な設計を心がけ、モックやスタブを活用する
パフォーマンス考慮事項
- 不要な再レンダリングを避けるため、適切なセレクターを使用する
- メモ化を活用して計算コストを削減する
- イベントハンドラーのメモリリークに注意する
これらの設計パターンを活用することで、大規模なアプリケーションでも保守性の高い状態管理を実現できます。プロジェクトの要件に応じて、適切なパターンを組み合わせてご活用ください。
関連リンク
- blog
うちのチーム、これやってない?アジャイル開発を腐らせる、ありがちなアンチパターン 10 選と処方箋
- blog
CD パイプラインを構築して、開発チームを「リリース疲れ」から解放しよう
- blog
見積もりが全然当たらないあなたへ。プランニングポーカーで楽しく、納得感のある見積もりをするコツ
- blog
「QA は最後の砦」という幻想を捨てる。開発プロセスに QA を組み込み、手戻りをなくす方法
- blog
ドキュメントは「悪」じゃない。アジャイル開発で「ちょうどいい」ドキュメントを見つけるための思考法
- blog
「アジャイルコーチ」って何する人?チームを最強にする影の立役者の役割と、あなたがコーチになるための道筋
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実
- review
え?世界はこんなに良くなってた!『FACTFULNESS』ハンス・ロスリングが暴く 10 の思い込みの正体
- review
瞬時に答えが出る脳に変身!『ゼロ秒思考』赤羽雄二が贈る思考力爆上げトレーニング
- review
関西弁のゾウに人生変えられた!『夢をかなえるゾウ 1』水野敬也が教えてくれた成功の本質
- review
「なぜ私の考えは浅いのか?」の答えがここに『「具体 ⇄ 抽象」トレーニング』細谷功