putcho01

Back

TABLE OF CONTENTS

Go で権限を型安全・拡張しやすく管理する —— 型・Proto・Composite で組み立てる権限ロジックBlur image

AI要約#

はじめに#

コンテンツ配信サービスのバックエンドでは、ユーザーが特定のコンテンツを利用できるかどうかを判定する際、サブスクリプションの会員ランク、購入履歴、利用国、公開スケジュールなど、さまざまな条件を考慮する必要があります。条件が増えるほど、判定ロジックは複雑になりがちです。

そこで、Go の型安全性Composite + Strategy パターンを手がかりに、利用権利の判定を「拡張しやすく・壊しにくく」どう設計するか考えてみます。

1. 想定サービス#

会員向けのコンテンツ配信バックエンドで、グローバル展開に伴い利用国制御も必要になるサービスを想定します。

  • 利用条件がコンテンツごとに異なる(サブスクリプションの会員ランク / 購入 / 誰でも可)
  • 権利情報が分散している(会員・購入・国や地域)
  • 複合条件(会員 OR 購入)AND 利用国 AND 公開状態、を満たす必要がある

このため、条件の整理と拡張しやすい実装が求められます。

2. 利用権利の整理(3 軸と論理式)#

2.1 三要素#

「あるコンテンツが利用可能か?」は、次の 3 つをすべて満たすときに true になります。

説明
会員レベルコンテンツが要求するサブスクリプションの会員ランク(誰でも / 無料 / 基本 / プレミアム / 権利なし)「基本会員以上で利用可能」など
利用国ユーザーの所属国が、コンテンツの利用国一覧に含まれるか特定国のみ利用可能なら他国は不可
公開状態現在時刻で、コンテンツが「公開開始」かどうか情報公開のみ・公開終了・コンテンツ公開停止のときは利用不可

さらに、会員購入OR の関係です。

  • 会員条件を満たす または
  • 購入による利用権利を保持している

のどちらかが成り立てば、「利用権利の種別」の条件は満たしたことになります。

2.2 論理式#

全体は次のように整理できます。

利用可能
  利用国に含まれる
  AND 公開状態が「公開開始」
  AND ( 会員条件を満たす OR 購入権利を保持 )
bash

この論理式を、ProtoGo の型・パターンで具体化していきます。

3. Proto での定義#

API やマスタで「利用に必要な条件」を表現するため、Protocol Buffers で 利用条件 を enum として定義します。

// コンテンツの提供方法(利用に必要な条件の種類)
enum Requirement {
  REQUIREMENT_UNSPECIFIED = 0;
  REQUIREMENT_FREE_TIER = 1;           // 無料会員
  REQUIREMENT_SUBSCRIPTION_TIER_BASIC = 2;   // 基本会員
  REQUIREMENT_SUBSCRIPTION_TIER_PREMIUM = 3; // プレミアム会員
  REQUIREMENT_PURCHASE = 4;            // 購入
}
protobuf
  • クライアントには「このコンテンツを利用するには、無料 / 基本 / プレミアム / 購入のいずれか(複数可)が必要」という情報を返します。
  • 公開状態や利用国は、別フィールド(public_status、コンテンツの利用国設定)で表現します。

公開状態も enum で定義します。

// コンテンツの公開ステータス
message PublicStatus {
  Status status = 1;
  ...

  enum Status {
    STATUS_UNSPECIFIED = 0;
    STATUS_PUBLISHED = 1;    // 公開開始
    STATUS_ARCHIVED = 2;     // 公開終了
    STATUS_CONFIDENTIAL = 3; // 非公開
  }
}
protobuf

4. Go の工夫①:Defined Type#

バックエンドの判定ロジックでは、マスタの文字列や生の enum 値をそのまま使わず、ドメイン用の型(Defined Type) でラップします。これにより、

  • 不正な値がドメインに流れ込まない
  • 「会員レベル」など、意味の単位がコード上で明確になる

というメリットがあります。

4.1 会員レベル#

利用に必要な「会員レベル」を表す型です。内部は string ですが、許容値は const と map で限定しています。

// Level は会員ステータスに応じた利用権限レベルを表します
type Level string

const (
	LevelEveryone      Level = "everyone"       // 誰でも利用可能
	LevelFreeMember    Level = "free-member"    // 無料会員なら利用可能
	LevelBasicMember   Level = "basic-member"   // 基本会員以上なら利用可能
	LevelPremiumMember Level = "premium-member" // プレミアム会員なら利用可能
	LevelNoPermission  Level = "no-permission"  // 会員による利用権限なし
)

var levelValues = map[string]Level{
	string(LevelEveryone):      LevelEveryone,
	string(LevelFreeMember):    LevelFreeMember,
	string(LevelBasicMember):   LevelBasicMember,
	string(LevelPremiumMember): LevelPremiumMember,
	string(LevelNoPermission):  LevelNoPermission,
}

func NewMembershipLevel(level string) (Level, error) {
	membershipLevel, ok := levelValues[level]
	if !ok {
		return "", fmt.Errorf("invalid membership level: %s", level)
	}
	return membershipLevel, nil
}

// Requirements は、そのレベルが「満たすべき会員」のリストを返す(例: 基本会員 → [基本, プレミアム])
func (l Level) Requirements() []Level {
	// ...
}
go
  • マスタの文字列は *NewMembershipLevel 経由でしか Level に変換できないため、typo や未定義の値がそのまま渡ることを防げます。

4.2 Proto との対応#

API レスポンス用の条件 enum は、Level と購入フラグから次のように変換します。

var requirementsMap = map[membership.Level]api.Requirement{
	membership.LevelFreeMember:    api.Requirement_REQUIREMENT_FREE_TIER,
	membership.LevelBasicMember:   api.Requirement_REQUIREMENT_SUBSCRIPTION_TIER_BASIC,
	membership.LevelPremiumMember: api.Requirement_REQUIREMENT_SUBSCRIPTION_TIER_PREMIUM,
}

// コンテンツが購入対象なら PURCHASE を追加し、Level.Requirements() に対応する enum を並べる
func toRequirements(level membership.Level, purchaseInfo *PurchaseInfo) []api.Requirement {
	var requirements []api.Requirement
	if purchaseInfo != nil {
		requirements = append(requirements, api.Requirement_REQUIREMENT_PURCHASE)
	}
	for _, requirement := range level.Requirements() {
		if requirement == membership.LevelNoPermission || requirement == membership.LevelEveryone {
			continue
		}
		requirements = append(requirements, requirementsMap[requirement])
	}
	return requirements
}
go

Proto の enum と Go のドメイン型の対応は この変換層に集約 することで、API の変更時も修正箇所を限定しやすくしています。

5. 構造体(判定に使う軸の所在)#

利用可否の判定では、会員レベル・利用国・公開状態の 3 軸を見ます。これらはコンテンツ(とその親)が持つフィールドと、現在時刻などから決まります。

type Content struct {
	// ...
	MembershipLevel membership.Level  // このコンテンツに必要な会員レベル
	// 対象国・公開スケジュールなどは省略
}

// 利用国: ユーザーの国がコンテンツ(または親コンテンツ)の公開対象に含まれるか
func (c *Content) IsDistributedInCountry(userCountry string, parent *ParentMaster) bool { ... }

// 公開状態: 現在時刻で「公開開始」かどうか
func (c *Content) IsPublished() bool { ... }
go
  • 会員レベル: MembershipLevel で「どの会員まで必要か」を表現し、4 章の Defined Type で型安全に扱います。
  • 利用国: IsDistributedInCountry に「コンテンツ or 親コンテンツの公開対象」とユーザー国を渡して判定します。
  • 公開状態: IsPublished() で、スケジュールと現在時刻から「公開開始か」だけ返します。

ここでは「どのフィールド・メソッドが 3 軸に対応するか」が分かれば十分で、公開対象の内部表現(集合型など)は 条件オブジェクトからは隠蔽されています。

6. Go の工夫②:Composite + Strategy#

「利用国 AND 公開状態 AND (会員 OR 購入)」を Composite パターン で木構造にし、各ノードは「条件」のインターフェースを実装して Strategy のように振る舞わせています。条件の追加はリーフを足すだけ、AND/OR の変更は Factory の組み立てを変えるだけで済み、簡単に条件を追加・変更できるようになります。

Ref: - Composite パターン

6.1 インターフェース#

// UseCondition はコンテンツを利用できる条件を抽象化したインターフェースです。
// 単一条件も AND/OR で組み合わせた条件も、同じ UseCondition として扱えるようにしています(Composite)。
type UseCondition interface {
	CanUse(ctx context.Context, user *user.User, profile *user.Profile) (bool, error)
}
go

「1 つの条件」も「AND/OR で組み合わせた条件」も同じ UseCondition として扱うのが Composite のポイントです。

6.2 リーフ条件#

いずれも UseCondition を実装した「リーフ」で、それぞれが Strategy のように振る舞います。

条件実装の役割
会員条件membershipUseConditionLevel に応じて外部 API で会員情報を取得し、プレミアム/基本/無料/誰でも/権利なしを判定
購入条件perpetualUseConditionリポジトリで「このユーザー × このコンテンツ」の購入権利の有無を確認
利用国条件countryUseConditioncontent.IsDistributedInCountry(profile.Country, parent) で判定
公開状態条件publishingStatusUseConditioncontent.IsPublished() で判定

6.3 AND / OR#

// 全ての条件を満たしていることを検証する UseCondition
type andCondition struct {
	conditions []UseCondition
}

func (c *andCondition) CanUse(ctx context.Context, user *user.User, profile *user.Profile) (bool, error) {
	for _, condition := range c.conditions {
		canUse, err := condition.CanUse(ctx, user, profile)
		if err != nil {
			return false, fmt.Errorf("one of and conditions not satisfied: %w", err)
		}
		if !canUse {
			return false, nil
		}
	}
	return true, nil
}

// いずれか 1 つの条件を満たすことを検証する UseCondition
type orCondition struct {
	conditions []UseCondition
}

func (c *orCondition) CanUse(ctx context.Context, user *user.User, profile *user.Profile) (bool, error) {
	for _, condition := range c.conditions {
		canUse, err := condition.CanUse(ctx, user, profile)
		if err != nil {
			continue // 他の条件が true を返す可能性があるので続行
		}
		if canUse {
			return true, nil
		}
	}
	return false, errors.Join(/* 集めたエラー */)
}
go
  • andCondition: すべての子 UseCondition が true のときだけ true
  • orCondition: どれか 1 つでも true なら true

6.4 木の組み立て#

コンテンツ・購入情報 から、1 本の UseCondition の木を組み立てます。

func (f *Factory) Create(
	content *Content,
	purchaseInfo *PurchaseInfo,
) (UseCondition, error) {
	// 利用権利の種別: 会員 OR 購入
	rightsCondition := newOrCondition(
		newMembershipUseCondition(content.MembershipLevel, f.membershipClient),
	)
	if purchaseInfo != nil {
		perpetualCondition := newPerpetualUseCondition(content.ID, f.perpetualUseRepo)
		rightsCondition = rightsCondition.Or(perpetualCondition)
	}

	// 全体: 利用国 AND 公開状態 AND (会員 OR 購入)
	countryCondition := newCountryUseCondition(content)
	publishedTimeCondition := newPublishedTimeUseCondition(content)
	return newAndCondition(
		countryCondition,
		publishedTimeCondition,
		rightsCondition,
	), nil
}
go

概念的には、次のような木になります。

AND
├── 利用国に含まれる
├── 公開状態が「公開開始」
└── OR
    ├── 会員条件を満たす
    └── 購入権利を保持(購入対象コンテンツの場合のみ)
bash

6.5 利用例#

useCondition, err := p.useConditionFactory.Create(content, content, purchaseInfoByContent[contentID])
if err != nil {
	return false, err
}
canUse, err := useCondition.CanUse(ctx, user, profile)
go

ハンドラでは、canUseerr の種類(利用国不一致・会員不足・公開前など)に応じて、クライアントに返すエラーコードを切り替えます。

6.6 新しい条件を追加する場合の例#

たとえば「期間限定イベントの参加者なら利用可能」という条件を後から足す場合です。

① 新しいリーフを 1 つ実装する

UseCondition を満たす構造体と CanUse を追加するだけです。

// イベント参加者かどうかを判定する UseCondition(新規追加)
type eventParticipantUseCondition struct {
	eventID string
	repo   EventParticipationRepository
}

func (c *eventParticipantUseCondition) CanUse(ctx context.Context, user *user.User, _ *user.Profile) (bool, error) {
	return c.repo.HasParticipation(ctx, user.ID, c.eventID)
}
go

② Factory の組み立てで OR に含める

既存の「会員 OR 購入」に「イベント参加」を加えるだけです。andConditionorCondition の実装はそのまま使えます。

// Create 内の一部(追加・変更箇所のみ)
rightsCondition := newOrCondition(
	newMembershipUseCondition(content.MembershipLevel, b.membershipClient),
)
if purchaseInfo != nil {
	rightsCondition = rightsCondition.Or(newPerpetualUseCondition(content.ID, b.perpetualPlayRightRepo))
}
if eventID != "" {
	rightsCondition = rightsCondition.Or(newEventParticipantUseCondition(eventID, b.eventRepo)) // 追加
}
// 以下、countryCondition, publishedTimeCondition との AND は変更なし
go

新しい条件を足すときは、リーフの実装Factory での組み立ての 2 箇所だけで済み、既存の AND/OR や他条件のコードには手を入れません。

7. まとめ#

  • 利用権利は「会員レベル × 利用国 × 公開状態」に分解し、会員と購入は OR で組み合わせる、という論理式で整理しました。
  • Proto では利用条件と公開状態の enum で API とマスタを定義し、Go では Defined Type(membership.Level など)で不正値を防ぎ、Proto との対応は変換層に集約しています。
  • 複合条件UseCondition インターフェースと Composite + Strategy で木構造にし、「1 条件」と「AND/OR の複合条件」を同じ型で扱えるようにしました。

新しい条件を足すときは、新しいリーフの UseCondition を追加し、Factory で木に組み込むだけでよく、既存の AND/OR や他条件の実装を触る必要がありません。Go の型とパターンで、権限ロジックを「壊しにくく・広げやすい」形にまとめた一例として、参考になれば幸いです。

profile

putcho01

サーバーサイドエンジニア。 GoとGCPをよく触っています。

Go で権限を型安全・拡張しやすく管理する —— 型・Proto・Composite で組み立てる権限ロジック
Author putcho01
Published at 2026年3月17日