

Go で権限を型安全・拡張しやすく管理する —— 型・Proto・Composite で組み立てる権限ロジック
Go で権限を型安全に判定するための設計パターンを紹介します。Composite パターンで複雑な権限判定を組み立てる方法について説明します。
AI要約#
はじめに#
コンテンツ配信サービスのバックエンドでは、ユーザーが特定のコンテンツを利用できるかどうかを判定する際、サブスクリプションの会員ランク、購入履歴、利用国、公開スケジュールなど、さまざまな条件を考慮する必要があります。条件が増えるほど、判定ロジックは複雑になりがちです。
そこで、Go の型安全性とComposite + Strategy パターンを手がかりに、利用権利の判定を「拡張しやすく・壊しにくく」どう設計するか考えてみます。
1. 想定サービス#
会員向けのコンテンツ配信バックエンドで、グローバル展開に伴い利用国制御も必要になるサービスを想定します。
- 利用条件がコンテンツごとに異なる(サブスクリプションの会員ランク / 購入 / 誰でも可)
- 権利情報が分散している(会員・購入・国や地域)
- 複合条件(会員 OR 購入)AND 利用国 AND 公開状態、を満たす必要がある
このため、条件の整理と拡張しやすい実装が求められます。
2. 利用権利の整理(3 軸と論理式)#
2.1 三要素#
「あるコンテンツが利用可能か?」は、次の 3 つをすべて満たすときに true になります。
| 軸 | 説明 | 例 |
|---|---|---|
| 会員レベル | コンテンツが要求するサブスクリプションの会員ランク(誰でも / 無料 / 基本 / プレミアム / 権利なし) | 「基本会員以上で利用可能」など |
| 利用国 | ユーザーの所属国が、コンテンツの利用国一覧に含まれるか | 特定国のみ利用可能なら他国は不可 |
| 公開状態 | 現在時刻で、コンテンツが「公開開始」かどうか | 情報公開のみ・公開終了・コンテンツ公開停止のときは利用不可 |
さらに、会員と購入は OR の関係です。
- 会員条件を満たす または
- 購入による利用権利を保持している
のどちらかが成り立てば、「利用権利の種別」の条件は満たしたことになります。
2.2 論理式#
全体は次のように整理できます。
利用可能 ⇔
利用国に含まれる
AND 公開状態が「公開開始」
AND ( 会員条件を満たす OR 購入権利を保持 )bashこの論理式を、Proto とGo の型・パターンで具体化していきます。
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; // 非公開
}
}protobuf4. 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
}goProto の 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 のように振る舞います。
| 条件 | 実装の役割 |
|---|---|
会員条件(membershipUseCondition) | Level に応じて外部 API で会員情報を取得し、プレミアム/基本/無料/誰でも/権利なしを判定 |
購入条件(perpetualUseCondition) | リポジトリで「このユーザー × このコンテンツ」の購入権利の有無を確認 |
利用国条件(countryUseCondition) | content.IsDistributedInCountry(profile.Country, parent) で判定 |
公開状態条件(publishingStatusUseCondition) | content.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
├── 会員条件を満たす
└── 購入権利を保持(購入対象コンテンツの場合のみ)bash6.5 利用例#
useCondition, err := p.useConditionFactory.Create(content, content, purchaseInfoByContent[contentID])
if err != nil {
return false, err
}
canUse, err := useCondition.CanUse(ctx, user, profile)goハンドラでは、canUse と err の種類(利用国不一致・会員不足・公開前など)に応じて、クライアントに返すエラーコードを切り替えます。
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 購入」に「イベント参加」を加えるだけです。andCondition や orCondition の実装はそのまま使えます。
// 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 の型とパターンで、権限ロジックを「壊しにくく・広げやすい」形にまとめた一例として、参考になれば幸いです。