4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

イベントベースで在庫管理を考えてみる

Posted at

これまでは、イベントを下記のようなステート(Entity とか)のための単なる手段として用いてきましたが、ある時に「ステートは本質的なものなのか?」と疑念が生じました。

  • イベントは Entity のようなステート(状態)を復元するための手段(イベントソーシング)
  • イベントはステートの状態変化を通知するための手段

そこで、改めて考えてみました。

  • ステートベース的な発想になっているのは、単にバイアスによる先入観や固定観念の影響なのではないか、そしてその根源はオブジェクト指向なのではないか
  • 設計技法・原則・パターン・フレームワーク・ベストプラクティス等は、思考や発想を型にはめる事で効率化を図っているようなものなので、バイアスとは常に表裏一体なのかもしれない
  • DDD(ドメイン駆動設計)のように特定のドメインに注目すると、複数のドメインに共通する本質を見逃す事にならないか、そもそもドメインエキスパートはエキスパートであるが故にバイアスに陥っている可能性が高いのではないか
  • 同じ出来事や事実に対する解釈が人の経験や立場に左右される事があるように、本質的な事実としてのイベントを処理等の都合に合わせて解釈したのがステートだと考えてみてはどうだろうか

ということで、以下のようなイベントベースの発想をしてみるとどうなのか、単純な在庫管理で試してみる事にしました。

  • ステートは本質ではなく、(本質的な事実を有する)イベントを解釈した結果
  • ステートはイベントのための手段

要は、イベントこそが重要で、ステート(Entity とか)は取るに足らない存在だと考えてみたって事です。

ドメインやコンテキスト等によって解釈の仕方は大きく変わるかもしれないが、本質的な事実(としてのイベント)はそれほど変化しないのではないかと仮定してみたという事でもあります。

在庫管理

本稿の在庫管理は、下記のような非常に単純なものを想定しました。

  • 何処に何の在庫(とりあえず現物のあるもの)がいくつあるという情報を管理

イベントを起点にして考えていき、参考のため TypeScript で実装してみます。

ソースコード一式は http://github.com/fits/try_samples/tree/master/Qiita/20210131/

(a) イベント定義

まずは、一般的な在庫管理で起きそうなイベント(出来事)を考えてみます。

具体的な処理内容やドメインを意識し過ぎないように注意して、本質を捉える事に集中する事にします。ここではゼロベース思考や素人目線などが効果的かもしれません。

在庫(数)そのものは 入庫出庫 という出来事の差異であり、これらは 2地点間を物が移動 ※ する過程で発生しているのだと考えてみると、開始 完了(終了) がありそうです。

 ※ 物理的な移動だけではなく、論理的なものも含む

ついでに、在庫管理のシステムでは一般的だと思われる 引当 も加えてみると、下記のような構成になりました。

  • 在庫移動の開始イベント
  • 在庫移動の完了イベント
  • 在庫移動のキャンセルイベント
  • 引当イベント
  • 引当した場合の出庫イベント
  • 引当しなかった場合の出庫イベント
  • 入庫イベント

今回は、引当や出庫等の成否をイベントとして分けていませんが、成功時と失敗時のイベントは分けておいた方が望ましいかもしれません。(例. 引当失敗イベント)

イベントの実装(定義)

これらのイベントを TypeScript で定義してみます。

用語は以下のようにしました。

  • 引当: assign
  • 出庫: ship
  • 入庫: arrive

本質的に必要そうな最低限の情報のみを持たせ、余計な情報は積極的に除外する事にしました。
在庫移動を一意に限定する ID や日付のようなメタデータ(として扱えば良さそうなもの)も除外しています。

全体的に、関数言語で使用する ADT(代数的データ型)を使ってイベントを定義してみました。

ADT を TypeScript で表現するため、文字列リテラル型のフィールド(下記の tag)を用いた interface として個々のイベントを定義しています。

在庫の対象や場所、数量の型をここで具体化する必要は無いため、Generics の型変数(ItemLocationQuantity)で表現しています。

models/events.ts
// 開始イベント
export interface StockMoveEventStarted<Item, Location, Quantity> {
    tag: 'stock-move-event.started'
    item: Item     // 対象(予定)
    qty: Quantity  // 数量(予定)
    from: Location // 移動元(予定)
    to: Location   // 移動先(予定)
}
// 完了イベント
export interface StockMoveEventCompleted {
    tag: 'stock-move-event.completed'
}
// キャンセルイベント
export interface StockMoveEventCancelled {
    tag: 'stock-move-event.cancelled'
}
// 引当イベント
export interface StockMoveEventAssigned<Item, Location, Quantity> {
    tag: 'stock-move-event.assigned'
    item: Item
    from: Location
    assigned: Quantity // 引当数
}
// 出庫イベント(引当なし)
export interface StockMoveEventShipped<Item, Location, Quantity> {
    tag: 'stock-move-event.shipped'
    item: Item
    from: Location
    outgoing: Quantity // 出庫数
}
// 出庫イベント(引当あり)
export interface StockMoveEventAssignShipped<Item, Location, Quantity> {
    tag: 'stock-move-event.assign-shipped'
    item: Item
    from: Location
    outgoing: Quantity
    assigned: Quantity // 出庫前の引当数
}
// 入庫イベント
export interface StockMoveEventArrived<Item, Location, Quantity> {
    tag: 'stock-move-event.arrived'
    item: Item
    to: Location
    incoming: Quantity // 入庫数
}
// 在庫移動イベントの構成
export type StockMoveEvent<Item, Location, Quantity> = 
    StockMoveEventStarted<Item, Location, Quantity> | 
    StockMoveEventCompleted | 
    StockMoveEventCancelled | 
    StockMoveEventAssigned<Item, Location, Quantity> | 
    StockMoveEventShipped<Item, Location, Quantity> | 
    StockMoveEventAssignShipped<Item, Location, Quantity> | 
    StockMoveEventArrived<Item, Location, Quantity>

(b) イベントの発生ルール

次に、これらのイベントの発生ルール(条件)を考えてみます。
ここでは、以下のようなステートマシンを考えてみました。

20210131_1.png

入庫の失敗状態は無し(0個の入庫で代用)で、状態遷移の基本パターンを 3通り用意しました。

  • (1) 引当 -> 出庫 -> 入庫
  • (2) 出庫 -> 入庫
  • (3) 入庫

(3) は出庫側の状況が不明なケースで入庫の記録だけを残すような用途を想定したものです。

このレイヤーからは、具体的な処理内容やドメインを意識していく事になると思いますが、このステートマシンはあくまでも (a) のイベントを扱う手段の 1つという事になるので、(a) のイベントの内容がこのステートマシンに特化したものとならないように注意しておきます。

また、このレイヤーはクリーンアーキテクチャだと Enterprise Business Rules(Entities の円)に相当する所だと考えています。

そうだとすると、本稿の考え方はクリーンアーキテクチャの Entities の更に中心に(本質的な)Event のレイヤーを設けるようなイメージなのかもしれません。

20210131_2.png

ステートマシンの実装

実装にあたって、以下のようなルールや制限を加えました。

  • 引当、入庫、出庫のロケーションは開始時に予定したものをそのまま使用
  • 在庫数のチェックは引当時にのみ実施
  • 在庫のタイプは 2種類
    • 在庫数を管理するタイプ(引当分の在庫が余っている場合にのみ引当が成功、在庫数は入庫イベントと出庫イベントから算出)
    • 在庫数を管理しないタイプ(引当は常に成功、在庫数は管理せず実質的に無限)
  • 引当数や出庫数が 0 の場合は(引当や出庫が)失敗したものとする

引当はこの処理内における単なる数値上の予約、入出庫は実作業の結果を反映する事を想定しています。

数値上の引当に成功しても実際の出庫が成功するとは限らず、数値上の在庫数以上の出庫が発生するようなケースも考えられるので、ここではそれらを許容するように実装しました。※

 ※ 在庫の整合性等をどのように制御・調整するかは
    この処理を利用する側に任せる事にします
    
    また、在庫数に不整合が生じた際に通知等を実施する役目も
    別のアプリケーションに任せます

ここでは、フレームワーク・永続化・同期/非同期・排他制御・ログやトレーシング等のような外部レイヤーの都合に影響されないよう、全体的に関数言語的な作りにして外部から振る舞いを持ち込めないような実装にしてみました。※

 ※ 例えば、依存性逆転の原則(DIP)を別の観点から考えてみると、
    内部的にどんな破壊的な振る舞い(ランタイムエラーとか)をするか分からないものを
    簡単に持ち込んでしまうという危険な側面もありそうですし、
    必然性のない(抽象への)依存を安易に作り出してしまう
    弊害の要因となるかもしれません。

在庫移動(StockMove)と在庫(Stock)のようなステート、あるいは各種アクション(開始など)の表現に関しても、イベントと同様に ADT を用いています。

実質的な処理内容は、イベントソーシングを用いてステートマシンを処理しているだけですが、在庫移動や在庫のようなステートは、このステートマシン内で (a) のイベントを扱うための手段だと割り切って、外部での利用を考慮していないのがポイントかもしれません。

つまり、以下のような考え方を適用しています。

  • ステートは特定の処理に特化させればよい ※
  • イベントは特定の処理に依存・特化させない
 ※ 処理毎にその処理に適した必要最低限のステート(在庫や在庫移動など)を用いて、
    他の処理の都合は基本的に考慮しない

例えば、在庫は ManagedUnmanaged という 2つのタイプで扱っていますが、これはこのステートマシンに限定したルールであり、(このステートマシン外の)別処理の在庫がこれに従う必要は全くありませんし、処理毎に(それに適した)異なる概念の在庫を用いれば問題ないと考えています。

models/stockmove.ts
import { 
    StockMoveEvent, StockMoveEventShipped, StockMoveEventAssignShipped 
} from './events'

export type ItemCode = string
export type LocationCode = string
export type Quantity = number

export type MoveEvent = StockMoveEvent<ItemCode, LocationCode, Quantity>

type ShippedMoveEvent = StockMoveEventShipped<ItemCode, LocationCode, Quantity>
type AssignShippedMoveEvent = StockMoveEventAssignShipped<ItemCode, LocationCode, Quantity>

// 在庫ステートの定義

// 管理対象外の在庫
interface StockUnmanaged {
    tag: 'stock.unmanaged'
    item: ItemCode
    location: LocationCode
}
// 管理対象の在庫
interface StockManaged {
    tag: 'stock.managed'
    item: ItemCode
    location: LocationCode
    qty: Quantity
    assigned: Quantity
}
// 在庫ステート
export type Stock = StockUnmanaged | StockManaged

// 在庫の処理
export class StockFunc {
    static newUnmanaged(item: ItemCode, location: LocationCode): Stock {
        return {
            tag: 'stock.unmanaged',
            item,
            location
        }
    }

    static newManaged(item: ItemCode, location: LocationCode): Stock {
        return {
            tag: 'stock.managed',
            item,
            location,
            qty: 0,
            assigned: 0
        }
    }
    // 在庫の有無を判定
    static isSufficient(stock: Stock, qty: Quantity): boolean {
        switch (stock.tag) {
            case 'stock.unmanaged':
                return true
            case 'stock.managed':
                return qty + Math.max(0, stock.assigned) <= Math.max(0, stock.qty)
        }
    }
}
// 在庫の復元
export class StockRestore {
    static restore(state: Stock, events: MoveEvent[]): Stock {
        return events.reduce(StockRestore.applyTo, state)
    }

    private static applyTo(state: Stock, event: MoveEvent): Stock {
        if (state.tag == 'stock.managed') {
            switch (event.tag) {
                case 'stock-move-event.assigned':
                    if (state.item == event.item && state.location == event.from) {
                        return StockRestore.updateAssigned(
                            state, 
                            state.assigned + event.assigned
                        )
                    }
                    break
                case 'stock-move-event.assign-shipped':
                    // ... 省略
                case 'stock-move-event.shipped':
                    // ... 省略
                case 'stock-move-event.arrived':
                    // ... 省略
            }
        }
        return state
    }

    private static updateStock(stock: Stock, qty: Quantity, assigned: Quantity): Stock {
        switch (stock.tag) {
            case 'stock.unmanaged':
                return stock
            case 'stock.managed':
                return {
                    tag: stock.tag,
                    item: stock.item,
                    location: stock.location,
                    qty,
                    assigned
                }
        }
    }

    // ... 省略

    private static updateAssigned(stock: Stock, assigned: Quantity): Stock {
        const qty = (stock.tag == 'stock.managed') ? stock.qty : 0
        return StockRestore.updateStock(stock, qty, assigned)
    }
}

interface StockMoveInfo {
    item: ItemCode
    qty: Quantity
    from: LocationCode
    to: LocationCode
}

// 在庫移動ステートの定義

interface StockMoveNothing {
    tag: 'stock-move.nothing'
}
// 開始
interface StockMoveDraft {
    tag: 'stock-move.draft'
    info: StockMoveInfo
}
// 完了
interface StockMoveCompleted {
    tag: 'stock-move.completed'
    info: StockMoveInfo
    outgoing: Quantity
    incoming: Quantity
}
// キャンセル
interface StockMoveCancelled {
    tag: 'stock-move.cancelled'
    info: StockMoveInfo
}
// 引当済
interface StockMoveAssigned {
    tag: 'stock-move.assigned'
    info: StockMoveInfo
    assigned: Quantity
}
// 出庫済
interface StockMoveShipped {
    tag: 'stock-move.shipped'
    info: StockMoveInfo
    outgoing: Quantity
}
// 入庫済
interface StockMoveArrived {
    tag: 'stock-move.arrived'
    info: StockMoveInfo
    outgoing: Quantity
    incoming: Quantity
}
// 引当失敗
interface StockMoveAssignFailed {
    tag: 'stock-move.assign-failed'
    info: StockMoveInfo
}
// 出庫失敗
interface StockMoveShipmentFailed {
    tag: 'stock-move.shipment-failed'
    info: StockMoveInfo
}
// 在庫移動ステート
export type StockMove = 
    StockMoveNothing | StockMoveDraft | StockMoveCompleted | 
    StockMoveCancelled | StockMoveAssigned | StockMoveShipped |
    StockMoveArrived | StockMoveAssignFailed | StockMoveShipmentFailed

// 在庫移動アクションの定義

// 開始
interface StockMoveStart {
    tag: 'stock-move.start'
    item: ItemCode
    qty: Quantity
    from: LocationCode
    to: LocationCode
}
// 完了
interface StockMoveComplete {
    tag: 'stock-move.complete'
}
// キャンセル
interface StockMoveCancel {
    tag: 'stock-move.cancel'
}
// 引当
interface StockMoveAssign {
    tag: 'stock-move.assign'
    stock: Stock
}
// 出庫
interface StockMoveShip {
    tag: 'stock-move.ship'
    outgoing: Quantity
}
// 入庫
interface StockMoveArrive {
    tag: 'stock-move.arrive'
    incoming: Quantity
}
// 在庫移動アクション
export type StockMoveAction = 
    StockMoveStart | StockMoveComplete | StockMoveCancel | 
    StockMoveAssign | StockMoveShip | StockMoveArrive

export type StockMoveResult = [StockMove, MoveEvent] | undefined
// 在庫移動の処理
export class StockMoveFunc {
    static initialState(): StockMove {
        return { tag: 'stock-move.nothing' }
    }

    static info(state: StockMove) {
        if (state.tag != 'stock-move.nothing') {
            return state.info
        }

        return undefined
    }

    static action(state: StockMove, act: StockMoveAction): StockMoveResult {
        switch (act.tag) {
            case 'stock-move.start':
                return StockMoveFunc.start(state, act.item, act.qty, act.from, act.to)
            case 'stock-move.complete':
                return StockMoveFunc.complete(state)
            case 'stock-move.cancel':
                return StockMoveFunc.cancel(state)
            case 'stock-move.assign':
                return StockMoveFunc.assign(state, act.stock)
            case 'stock-move.ship':
                return StockMoveFunc.ship(state, act.outgoing)
            case 'stock-move.arrive':
                return StockMoveFunc.arrive(state, act.incoming)
        } 
    }

    private static start(state: StockMove, item: ItemCode, qty: Quantity, 
        from: LocationCode, to: LocationCode): StockMoveResult {

        if (qty < 1) {
            return undefined
        }

        const event: MoveEvent = {
            tag: 'stock-move-event.started',
            item,
            qty,
            from,
            to
        }

        return StockMoveFunc.applyTo(state, event)
    }

    private static assign(state: StockMove, stock: Stock): StockMoveResult {
        const info = StockMoveFunc.info(state)

        if (info && info.item == stock.item && info.from == stock.location) {
            const assigned = 
                (stock && StockFunc.isSufficient(stock, info.qty)) ? info.qty : 0
            
            const event: MoveEvent = {
                tag: 'stock-move-event.assigned',
                item: info.item,
                from: info.from,
                assigned
            }

            return StockMoveFunc.applyTo(state, event)
        }

        return undefined
    }

    // ... 省略

    private static applyTo(state: StockMove, event: MoveEvent): StockMoveResult {
        const nextState = StockMoveRestore.restore(state, [event])

        return (nextState != state) ? [nextState, event] : undefined
    }

    // ... 省略
}
// 在庫移動の復元
export class StockMoveRestore {
    static restore(state: StockMove, events: MoveEvent[]): StockMove {
        return events.reduce(StockMoveRestore.applyTo, state)
    }

    private static applyTo(state: StockMove, event: MoveEvent): StockMove {
        switch (state.tag) {
            case 'stock-move.nothing':
                if (event.tag == 'stock-move-event.started') {
                    return {
                        tag: 'stock-move.draft',
                        info: {
                            item: event.item,
                            qty: event.qty,
                            from: event.from,
                            to: event.to
                        }
                    }
                }
                break
            case 'stock-move.draft':
                return StockMoveRestore.applyEventToDraft(state, event)
            case 'stock-move.assigned':
                // ... 省略
            case 'stock-move.shipped':
                // ... 省略
            case 'stock-move.arrived':
                // ... 省略
            case 'stock-move.completed':
            case 'stock-move.cancelled':
            case 'stock-move.assign-failed':
            case 'stock-move.shipment-failed':
                break
        }
        return state
    }

    // ... 省略

    private static applyEventToDraft(state: StockMoveDraft, event: MoveEvent): StockMove {
        switch (event.tag) {
            case 'stock-move-event.cancelled':
                // ... 省略
            case 'stock-move-event.assigned':
                if (state.info.item == event.item && state.info.from == event.from) {
                    if (event.assigned > 0) {
                        return {
                            tag: 'stock-move.assigned',
                            info: state.info,
                            assigned: event.assigned
                        }
                    }
                    else {
                        return {
                            tag: 'stock-move.assign-failed',
                            info: state.info
                        }
                    }
                }
                break
            case 'stock-move-event.shipped':
                // ... 省略
            case 'stock-move-event.arrived':
                // ... 省略
        }

        return state
    }
}

(c) API 化(GraphQL + MongoDB)

ここでは、(b) のステートマシンをマイクロサービスとして利用できるように Web API 化してみます。

ステートマシン的な処理は、リソース操作を前提とした REST API には合わないと思うので、下記を用いて GraphQL API 化する事にしました。

GraphQL の input 型は現時点で union のような構成にできないようなので(RFC: GraphQL Input Union で検討はされている模様)、アクション別に分けてみました。

このレイヤーは、クリーンアーキテクチャだと Use Cases やその外側のレイヤーに相当すると思うので、フレームワークや DB 等の都合に合わせて割と自由に実装すれば良いと考えています。(コントローラー・サービス・リポジトリを用いたりとか)

なお、オブジェクト指向的な考え方を適用したり、DDD のエンティティや集約などを用いるのも、このレイヤーからにしておいた方が何かと都合が良さそうな気がしています。

index.ts
import { ApolloServer, gql } from 'apollo-server'
import { v4 as uuidv4 } from 'uuid'
import { MongoClient, Collection } from 'mongodb'

import {
    ItemCode, LocationCode, MoveEvent, StockMoveAction,
    StockMoveFunc, StockMoveRestore, StockMove, StockMoveResult,
    StockFunc, StockRestore, Stock
} from './models'

// ... 省略

type EventId = number
type MoveId = string
type Revision = number
// 永続化用のイベント
interface StoredEvent {
    _id: EventId
    move_id: MoveId
    revision: Revision
    item: ItemCode
    from: LocationCode
    to: LocationCode
    event: MoveEvent
}
// 復元用の在庫移動
interface RestoredStockMove {
    state: StockMove
    revision: Revision
}
// 永続化処理
class Store {
    // ... 省略

    async loadStock(item: ItemCode, location: LocationCode): Promise<Stock | undefined> {
        const id = this.stockId(item, location)
        const stock = await this.stocksCol.findOne({ _id: id })

        if (!stock) {
            return undefined
        }

        const query = {
            '$and': [
                { item },
                { '$or': [
                    { from: location },
                    { to: location }
                ]}
            ]
        }

        const events = await this.eventsCol
            .find(query)
            .sort({ _id: 1 })
            .map(r => r.event)
            .toArray()

        return StockRestore.restore(stock, events)
    }

    async saveStock(stock: Stock): Promise<void> {
        // ... 省略
    }

    async loadMove(moveId: MoveId): Promise<RestoredStockMove | undefined> {
        const events: StoredEvent[] = await this.eventsCol
            .find({ move_id: moveId })
            .sort({ revision: 1 })
            .toArray()

        const state = StockMoveFunc.initialState()
        const revision = events.reduce((acc, e) => Math.max(acc, e.revision), 0)

        const res = StockMoveRestore.restore(state, events.map(e => e.event))

        return (res == state) ? undefined : { state: res, revision }
    }

    async saveEvent(event: StoredEvent): Promise<void> {
        // ... 省略
    }

    // ... 省略
}
// GraphQL スキーマ定義
const typeDefs = gql(`
    type StockMoveInfo {
        item: ID!
        qty: Int!
        from: ID!
        to: ID!
    }

    interface StockMove {
        id: ID!
        info: StockMoveInfo!
    }

    type DraftStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
    }

    type CompletedStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
        outgoing: Int!
        incoming: Int!
    }

    type CancelledStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
    }

    type AssignedStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
        assigned: Int!
    }

    type ShippedStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
        outgoing: Int!
    }

    type ArrivedStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
        outgoing: Int!
        incoming: Int!
    }

    type AssignFailedStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
    }

    type ShipmentFailedStockMove implements StockMove {
        id: ID!
        info: StockMoveInfo!
    }

    interface Stock {
        item: ID!
        location: ID!
    }

    type UnmanagedStock implements Stock {
        item: ID!
        location: ID!
    }

    type ManagedStock implements Stock {
        item: ID!
        location: ID!
        qty: Int!
        assigned: Int!
    }

    input CreateStockInput {
        item: ID!
        location: ID!
    }

    input StartMoveInput {
        item: ID!
        qty: Int!
        from: ID!
        to: ID!
    }

    type Query {
        findStock(item: ID!, location: ID!): Stock
        findMove(id: ID!): StockMove
    }

    type Mutation {
        createManaged(input: CreateStockInput!): ManagedStock
        createUnmanaged(input: CreateStockInput!): UnmanagedStock

        start(input: StartMoveInput!): StockMove
        assign(id: ID!): StockMove
        ship(id: ID!, outgoing: Int!): StockMove
        arrive(id: ID!, incoming: Int!): StockMove
        complete(id: ID!): StockMove
        cancel(id: ID!): StockMove
    }
`)

const toStockMoveForGql = (id: MoveId, state: StockMove | undefined) => {
    if (state) {
        return { id, ...state }
    }
    return undefined
}

const doMoveAction = async (store: Store, rs: RestoredStockMove | undefined, 
    id: MoveId, act: StockMoveAction) => {

    if (rs) {
        const res = StockMoveFunc.action(rs.state, act)

        if (res) {
            const [mv, ev] = res
            const info = StockMoveFunc.info(mv)

            if (info) {
                const event = { 
                    _id: 0,
                    move_id: id, 
                    revision: rs.revision + 1,
                    item: info.item,
                    from: info.from,
                    to: info.to,
                    event: ev
                }

                await store.saveEvent(event)
    
                return toStockMoveForGql(id, mv)
            }
        }
    }
    return undefined
}
// GraphQL 処理の実装
const resolvers = {
    Stock: {
        __resolveType: (obj, ctx, info) => {
            if (obj.tag == 'stock.managed') {
                return 'ManagedStock'
            }
            return 'UnmanagedStock'
        }
    },
    StockMove: {
        __resolveType: (obj: StockMove, ctx, info) => {
            switch (obj.tag) {
                case 'stock-move.draft':
                    return 'DraftStockMove'
                case 'stock-move.completed':
                    return 'CompletedStockMove'
                case 'stock-move.cancelled':
                    return 'CancelledStockMove'
                case 'stock-move.assigned':
                    return 'AssignedStockMove'
                case 'stock-move.shipped':
                    return 'ShippedStockMove'
                case 'stock-move.arrived':
                    return 'ArrivedStockMove'
                case 'stock-move.assign-failed':
                    return 'AssignFailedStockMove'
                case 'stock-move.shipment-failed':
                    return 'ShipmentFailedStockMove'
            }
            return undefined
        }
    },
    Query: {
        findStock: async (parent, { item, location }, { store }, info) => {
            return store.loadStock(item, location)
        },
        findMove: async (parent, { id }, { store }, info) => {
            const res = await store.loadMove(id)
            return toStockMoveForGql(id, res?.state)
        }
    },
    Mutation: {
        createManaged: async (parent, { input: { item, location } }, { store }, info) => {
            const s = StockFunc.newManaged(item, location)

            await store.saveStock(s)

            return s
        },
        createUnmanaged: async (parent, { input: { item, location } }, { store }, info) => {
            const s = StockFunc.newUnmanaged(item, location)

            await store.saveStock(s)

            return s
        },
        start: async (parent, { input: { item, qty, from, to } }, { store }, info) => {
            const rs = { state: StockMoveFunc.initialState(), revision: 0 }
            const id = `move-${uuidv4()}`

            return doMoveAction(
                store, rs, id, 
                { tag: 'stock-move.start', item, qty, from, to }
            )
        },
        assign: async(parent, { id }, { store }, info) => {
            const rs = await store.loadMove(id)

            if (rs) {
                const info = StockMoveFunc.info(rs.state)

                if (info) {
                    const stock = await store.loadStock(info.item, info.from)

                    return doMoveAction(
                        store, rs, id, 
                        { tag: 'stock-move.assign', stock }
                    )
                }
            }
            return undefined
        },
        ship: async(parent, { id, outgoing }, { store }, info) => {
            // ... 省略
        },
        arrive: async(parent, { id, incoming }, { store }, info) => {
            // ... 省略
        },
        complete: async(parent, { id }, { store }, info) => {
            // ... 省略
        },
        cancel: async(parent, { id }, { store }, info) => {
            // ... 省略
        }
    }
}

const run = async () => {
    const mongo = await MongoClient.connect(mongoUrl, { useUnifiedTopology: true })
    const db = mongo.db(dbName)

    const store = new Store(
        db.collection(colName), 
        db.collection(seqColName),
        db.collection(stocksColName)
    )

    const server = new ApolloServer({
        typeDefs, 
        resolvers, 
        context: {
            store
        }
    })

    const res = await server.listen()

    console.log(res.url)
}

run().catch(err => console.error(err))

備考

正直、単純なステートマシンを 1つ API 化しただけでは本稿の考え方の有効性は判断できませんが、新たな視点や気付きは得られたように思います。

最後に、機能拡張や高度な在庫管理が必要となった場合にどのような手段をとれるか考えてみました。

    1. 別のアプリケーション(マイクロサービス等)から (c) もしくは (c) の結果を利用する
    1. (c) の部分を新しく作る
    1. (b) と (c) の部分を新しく作る
    1. (a) ~ (c) の部分を新しく作る

例えば、複数の在庫をグルーピングしたり複数の移動を伴うような在庫移動マイクロサービスが、在庫管理のコア機能として本稿の (c) を利用するような事が考えられます。

(c) で MongoDB へ保存したイベントデータを CDC(変更データキャプチャ)等で取得し、ドメインイベント等へ変換してメッセージブローカーへ通知するようなアプリケーションを別に作って、コレオグラフィ型のマイクロサービス連携やイベント駆動型アーキテクチャを実現するような事も考えられます。※

 ※ メッセージブローカーから取得したイベントデータを基に
    検索用の在庫データを組み立てて全文検索エンジンへ登録するとか、
    データ分析用の時系列データとしてデータレイクに溜めるとかも考えられます

また、(b) を WebAssembly とかでコンポーネント化して(主にサーバーサイドで)再利用してみたりするのもありかもしれません。

4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?