《后端架构设计·演进与治理》11.1 平台即产品:能力边界与用户旅程

从 arch-lab 订单团队为一条部署流水线连开三张工单、却没有人对结果负责的场景切入,说明平台能力目录为什么必须先写非目标与退出条件,如何用五种需求路由处理新建、扩展、迁移与等待,并给出带依赖环和退役替换校验的 Go 实现与本机测试输出。

三个平台都接了一个部署需求

2026 年 10 月,arch-lab 的订单团队要给一个新服务接部署流水线。他们先找 CI 组,CI 组说流水线模板归交付平台;交付平台说运行环境归基础设施;基础设施说发布审批归 SRE。三张工单最后各交付了一段:CI 给了 Jenkinsfile,基础设施给了命名空间,SRE 给了审批表。没有一张工单对“新服务两周内能安全上线”负责。

这不是协作态度问题,而是平台没有产品边界。每个团队都在自己的能力范围内接单,但没有人定义这项能力的入口、出口、非目标和唯一责任人。平台工程最常见的失败不是技术选型错误,而是把平台做成了工单队列:需求来了就接,接了就定制,定制完无法复用。平台工程 讨论过平台团队的组织形态,最小治理 讨论过决策权;本节只回答一个更靠前的问题:一项能力凭什么算“平台产品”。

能力目录先写“不做什么”

平台即产品,第一个交付物不是控制台,而是一份可评审的能力目录。每项能力至少要有唯一 ID、负责人、服务等级、交付 tier、生命周期状态、消费方、进入条件、退出条件、非目标、依赖项和退役替换。

字段缺失时的典型后果本节校验规则
负责人出问题无人拍板,工单在团队间转必填
进入/退出条件谁都能用,谁都不知道怎么下线两者都必填
非目标同一类定制需求反复出现至少一条
生命周期计划中的能力被当成已上线planned 不得列消费方
退役替换宣布退役却没有迁移目的地deprecated/retired 必须指向可用替代
依赖目录内部成环,边界没有拆清未知依赖与环都报错

非目标这一栏最容易被省略,也最能区分产品与项目。“部署平台”如果不写“不负责数据库迁移”,每个新服务都会来问一次;写了之后,拒绝就有了依据,而不是靠某个人当天的心情。退役替换同理:只宣布退役、不给出 GA 或 beta 的替代品,等于把迁移成本原样推给消费方。

能力之间还有依赖。部署依赖数据库,数据库依赖网络;目录里出现环,通常说明两个团队仍在共享同一份职责。本节用深度优先遍历检测环,而不是等到排期时才发现“谁都等谁”。

需求路由:五种结果,不是“接单/不接单”

有了目录,平台才能对需求做路由,而不是对每个请求都开一个新项目。Decide 只看四个输入:请求团队、目标能力、是否需要自助、是否新消费方。

请求信号路由含义后续动作
没有对应能力build承认目录有空白进入平台产品待办,而不是悄悄定制
能力已退役或弃用migrate旧入口不再扩展返回替代能力,迁移列入双方计划
能力仍是 plannedwait未发布能力不承诺日期登记需求,不占用交付资源
需要自助但 tier 不支持extend平台自己的产品缺口补自助化,不是给单团队开特例
ga/beta 且匹配adopt正常消费beta 新消费方需负责人签字

用户旅程要用同一只时钟

能力是否存在,和团队能否顺利使用,是两件事。arch-lab 把一次能力采纳拆成发现、申请、审批、开通、验证五个阶段,每阶段记录进入时间、离开时间和是否人工交接。

两个指标比总时长更有用。第一是空闲时间:阶段之间的等待不属于任何阶段,却往往是 lead time 的主要部分;第二是人工交接次数,每次交接都可能丢上下文。旅程统计必须传入一个固定的 now,否则同一份数据在不同时间跑出不同结果,无法比较。

同包的 journey.go 用显式 now 计算总时长、活动时间、空闲时间、最慢阶段和人工交接数。固定时钟测试的结果是:总时长 65 分钟,活动 50 分钟,排队 15 分钟,最慢阶段是人工审批的 20 分钟。这个分解直接指出该优化审批,而不是笼统地说“平台慢”。

可运行实现:能力目录与需求路由

下面的 catalog 部分只依赖标准库。Validate 返回全部违规而不是第一条;Decide 不修改目录,只返回路由与理由。

// file: internal/platform/idp/catalog.go
// tier: L1
// verify: go test ./internal/platform/idp/ -count=1
package idp

import (
	"errors"
	"fmt"
	"strings"
)

type CapabilityTier string

const (
	TierSelfService CapabilityTier = "self-service"
	TierSupported   CapabilityTier = "supported"
	TierConsulting  CapabilityTier = "consulting"
)

type CapabilityStatus string

const (
	StatusPlanned    CapabilityStatus = "planned"
	StatusBeta       CapabilityStatus = "beta"
	StatusGA         CapabilityStatus = "ga"
	StatusDeprecated CapabilityStatus = "deprecated"
	StatusRetired    CapabilityStatus = "retired"
)

var (
	validTiers  = map[CapabilityTier]bool{TierSelfService: true, TierSupported: true, TierConsulting: true}
	validStates = map[CapabilityStatus]bool{StatusPlanned: true, StatusBeta: true,
		StatusGA: true, StatusDeprecated: true, StatusRetired: true}
)

type Capability struct {
	ID, Name, Owner     string
	Tier                CapabilityTier
	Status              CapabilityStatus
	Consumers           []string
	SLA, Entry, Exit    string
	NonGoals, DependsOn []string
	Replacement         string
}
type Catalog struct {
	Version      string
	Capabilities []Capability
}

var (
	ErrUnknownCapability = errors.New("idp: unknown capability")
	ErrInvalidRequest    = errors.New("idp: invalid request")
)

func blank(s string) bool { return strings.TrimSpace(s) == "" }

func (c Catalog) find(id string) (Capability, bool) {
	for _, cap := range c.Capabilities {
		if cap.ID == id {
			return cap, true
		}
	}
	return Capability{}, false
}

func (c Catalog) Validate() []error {
	var errs []error
	if blank(c.Version) {
		errs = append(errs, errors.New("catalog: version is empty"))
	}
	byID := make(map[string]Capability, len(c.Capabilities))
	for i, cap := range c.Capabilities {
		where := fmt.Sprintf("capability[%d]", i)
		if blank(cap.ID) {
			errs = append(errs, fmt.Errorf("%s: id is empty", where))
			continue
		}
		where = fmt.Sprintf("capability %q", cap.ID)
		if _, dup := byID[cap.ID]; dup {
			errs = append(errs, fmt.Errorf("%s: duplicate id", where))
			continue
		}
		byID[cap.ID] = cap
		switch {
		case blank(cap.Name) || blank(cap.Owner) || blank(cap.SLA) || blank(cap.Entry) || blank(cap.Exit):
			errs = append(errs, fmt.Errorf("%s: name, owner, sla, entry and exit are required", where))
		case !validTiers[cap.Tier]:
			errs = append(errs, fmt.Errorf("%s: invalid tier %q", where, cap.Tier))
		case !validStates[cap.Status]:
			errs = append(errs, fmt.Errorf("%s: invalid status %q", where, cap.Status))
		case len(cap.NonGoals) == 0:
			errs = append(errs, fmt.Errorf("%s: no non-goals declared", where))
		case cap.Status == StatusPlanned && len(cap.Consumers) > 0:
			errs = append(errs, fmt.Errorf("%s: planned capability already lists consumers", where))
		case (cap.Status == StatusDeprecated || cap.Status == StatusRetired) && blank(cap.Replacement):
			errs = append(errs, fmt.Errorf("%s: %s without replacement", where, cap.Status))
		}
	}
	for _, cap := range c.Capabilities {
		if cap.ID == "" {
			continue
		}
		for _, dep := range cap.DependsOn {
			if _, ok := byID[dep]; !ok {
				errs = append(errs, fmt.Errorf("capability %q: unknown dependency %q", cap.ID, dep))
			}
		}
		if cap.Replacement != "" {
			repl, ok := byID[cap.Replacement]
			if !ok {
				errs = append(errs, fmt.Errorf("capability %q: unknown replacement %q", cap.ID, cap.Replacement))
			} else if repl.Status != StatusGA && repl.Status != StatusBeta {
				errs = append(errs, fmt.Errorf("capability %q: replacement %q is not available", cap.ID, repl.ID))
			}
		}
	}
	color := make(map[string]int, len(byID))
	var visit func(string, []string)
	visit = func(id string, path []string) {
		color[id] = 1
		for _, dep := range byID[id].DependsOn {
			if _, ok := byID[dep]; !ok {
				continue
			}
			if color[dep] == 1 {
				errs = append(errs, fmt.Errorf("catalog: dependency cycle: %s", strings.Join(append(path, dep), " -> ")))
			} else if color[dep] == 0 {
				visit(dep, append(path, dep))
			}
		}
		color[id] = 2
	}
	for _, cap := range c.Capabilities {
		if cap.ID != "" && color[cap.ID] == 0 {
			visit(cap.ID, []string{cap.ID})
		}
	}
	return errs
}

type FitRequest struct {
	Team             string
	CapabilityID     string
	NeedsSelfService bool
	NewConsumer      bool
}

type Decision string

const (
	DecisionAdopt   Decision = "adopt"
	DecisionExtend  Decision = "extend"
	DecisionBuild   Decision = "build"
	DecisionMigrate Decision = "migrate"
	DecisionWait    Decision = "wait"
)

type DecisionResult struct {
	Decision Decision
	Target   string
	Reason   string
}

func (c Catalog) Decide(req FitRequest) (DecisionResult, error) {
	if blank(req.Team) {
		return DecisionResult{}, fmt.Errorf("%w: team is empty", ErrInvalidRequest)
	}
	if blank(req.CapabilityID) {
		return DecisionResult{Decision: DecisionBuild, Reason: "no capability covers the need"}, nil
	}
	cap, ok := c.find(req.CapabilityID)
	if !ok {
		return DecisionResult{}, fmt.Errorf("%w: %s", ErrUnknownCapability, req.CapabilityID)
	}
	switch cap.Status {
	case StatusRetired, StatusDeprecated:
		return DecisionResult{Decision: DecisionMigrate, Target: cap.Replacement, Reason: "capability is being retired"}, nil
	case StatusPlanned:
		return DecisionResult{Decision: DecisionWait, Target: cap.ID, Reason: "capability is not available yet"}, nil
	}
	if req.NeedsSelfService && cap.Tier != TierSelfService {
		return DecisionResult{Decision: DecisionExtend, Target: cap.ID, Reason: "self-service requested, tier is " + string(cap.Tier)}, nil
	}
	if req.NewConsumer && cap.Status == StatusBeta {
		return DecisionResult{Decision: DecisionAdopt, Target: cap.ID, Reason: "beta adoption needs owner sign-off"}, nil
	}
	return DecisionResult{Decision: DecisionAdopt, Target: cap.ID, Reason: "capability matches request"}, nil
}

Validate 的检查顺序刻意把“结构错误”和“依赖错误”分开:前者在单条能力上就能判断,后者需要完整目录。这样一条错误不会掩盖另一条,评审时能一次改完。Decide 先判断能力是否存在,再判断生命周期,最后才看 tier;顺序反了会把“已退役”误判成“需要自助化”。

测试:五种边界违规与八种路由

测试用一张最小目录覆盖 GA、退役、planned、beta 四种状态,并逐个注入非目标缺失、重复 ID、依赖环、退役无替换、planned 带消费方五类错误。

// file: internal/platform/idp/catalog_test.go
// tier: L1
// verify: go test ./internal/platform/idp/ -count=1 -run 'TestCatalog|TestDecide|TestJourney'
package idp

import (
	"errors"
	"strings"
	"testing"
)

func capOf(id string, tier CapabilityTier, status CapabilityStatus) Capability {
	return Capability{ID: id, Name: id, Owner: "platform", Tier: tier, Status: status,
		SLA: "99.9%", Entry: "team owns a schema", Exit: "export and delete",
		NonGoals: []string{"analytics queries"}}
}

func validCatalog() Catalog {
	c := Catalog{Version: "2026-11-01", Capabilities: []Capability{
		capOf("cap-db", TierSelfService, StatusGA),
		capOf("cap-deploy", TierSupported, StatusGA),
		capOf("cap-legacy", TierConsulting, StatusRetired),
		capOf("cap-queue", TierSelfService, StatusPlanned),
		capOf("cap-cache", TierSelfService, StatusBeta),
	}}
	c.Capabilities[1].DependsOn = []string{"cap-db"}
	c.Capabilities[1].Consumers = []string{"order"}
	c.Capabilities[2].Replacement = "cap-deploy"
	return c
}

func TestCatalogValidate(t *testing.T) {
	if errs := validCatalog().Validate(); len(errs) != 0 {
		t.Fatalf("valid catalog: %v", errs)
	}
	cases := []struct {
		name string
		mut  func(*Catalog)
		want string
	}{
		{"no_non_goals", func(c *Catalog) { c.Capabilities[0].NonGoals = nil }, "no non-goals"},
		{"duplicate", func(c *Catalog) { c.Capabilities[3].ID = "cap-db" }, "duplicate id"},
		{"cycle", func(c *Catalog) { c.Capabilities[0].DependsOn = []string{"cap-deploy"} }, "dependency cycle"},
		{"retired_without_replacement", func(c *Catalog) { c.Capabilities[2].Replacement = "" }, "retired without replacement"},
		{"planned_with_consumers", func(c *Catalog) { c.Capabilities[3].Consumers = []string{"order"} }, "planned capability"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			c := validCatalog()
			tc.mut(&c)
			for _, err := range c.Validate() {
				if strings.Contains(err.Error(), tc.want) {
					t.Logf("%s -> %v", tc.name, err)
					return
				}
			}
			t.Fatalf("want error containing %q, got %v", tc.want, c.Validate())
		})
	}
}

func TestDecide(t *testing.T) {
	c := validCatalog()
	cases := []struct {
		name string
		req  FitRequest
		want Decision
		err  error
	}{
		{"no_team", FitRequest{CapabilityID: "cap-db"}, "", ErrInvalidRequest},
		{"no_capability", FitRequest{Team: "order"}, DecisionBuild, nil},
		{"unknown", FitRequest{Team: "order", CapabilityID: "cap-nope"}, "", ErrUnknownCapability},
		{"retired", FitRequest{Team: "order", CapabilityID: "cap-legacy"}, DecisionMigrate, nil},
		{"planned", FitRequest{Team: "order", CapabilityID: "cap-queue"}, DecisionWait, nil},
		{"needs_self_service", FitRequest{Team: "order", CapabilityID: "cap-deploy", NeedsSelfService: true}, DecisionExtend, nil},
		{"beta_signoff", FitRequest{Team: "order", CapabilityID: "cap-cache", NewConsumer: true}, DecisionAdopt, nil},
		{"adopt", FitRequest{Team: "order", CapabilityID: "cap-db"}, DecisionAdopt, nil},
	}
	for _, tc := range cases {
		got, err := c.Decide(tc.req)
		if !errors.Is(err, tc.err) || got.Decision != tc.want {
			t.Fatalf("%s: got %+v, %v; want %s, %v", tc.name, got, err, tc.want, tc.err)
		}
		t.Logf("%s -> %s %s (%s)", tc.name, got.Decision, got.Target, got.Reason)
	}
}

本机实测

环境为 macOS darwin/arm64、Go 1.26.0。命令与原始输出节选如下,完整日志保存在 .local/reports/be-arch-gov/ch11-full.txt。

$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/idp/ -count=1 -v -run 'TestCatalog|TestDecide|TestJourney'
=== RUN   TestCatalogValidate
    catalog_test.go:53: duplicate -> [capability "cap-db": duplicate id]
    catalog_test.go:53: cycle -> [catalog: dependency cycle: cap-db -> cap-deploy -> cap-db]
    catalog_test.go:53: retired_without_replacement -> [capability "cap-legacy": retired without replacement]
    catalog_test.go:53: planned_with_consumers -> [capability "cap-queue": planned capability already lists consumers]
--- PASS: TestCatalogValidate (0.00s)
=== RUN   TestDecide
    catalog_test.go:84: no_capability -> build  (no capability covers the need)
    catalog_test.go:84: retired -> migrate cap-deploy (capability is being retired)
    catalog_test.go:84: planned -> wait cap-queue (capability is not available yet)
    catalog_test.go:84: needs_self_service -> extend cap-deploy (self-service requested, tier is supported)
    catalog_test.go:84: beta_signoff -> adopt cap-cache (beta adoption needs owner sign-off)
--- PASS: TestDecide (0.00s)
=== RUN   TestJourneyAssess
    journey_test.go:30: total=1h5m0s active=50m0s idle=15m0s worst=approve(20m0s) manual=1
--- PASS: TestJourneyAssess (0.00s)
PASS
ok  	archlab/internal/platform/idp	0.460s

这些数字的边界也要说清楚:目录校验是纯内存、单进程的结构检查,不验证 SLA 是否可达,也不验证负责人是否真的在岗;旅程时长来自固定构造的时间点,不代表真实工单的分布。生产上应把真实工单的时间戳导入同一结构重跑,而不是引用本节的合成值。

常见错误与边界

  • 把能力目录写成服务清单。服务清单回答“有哪些进程”,能力目录回答“谁能为哪类需求负责到什么程度”,两者不能互相替代。
  • 只写目标不写非目标。没有非目标,能力边界会随着每个新需求扩张,最后又回到工单队列。
  • 用 planned 能力接真实流量。计划中的能力没有 SLA 和退出路径,提前消费会把平台的产品待办变成生产事故。
  • 退役只发公告不给替代。没有 GA/beta 替换目标时,migrate 路由无法落地,消费方只能继续用旧能力。

小结

平台即产品的第一件事,是把“我们提供什么”写成带负责人、SLA、进入退出条件、非目标和依赖的能力目录。目录不是文档装饰,它让 build、extend、migrate、wait、adopt 五种路由有据可依,也让“不接这个需求”成为产品决策而不是个人态度。

用户旅程则补上另一半:能力存在不等于可用。用固定时钟把总时长拆成活动、空闲和最慢阶段,才能看出该优化审批、开通还是文档。下一节把目录里的某项能力落成黄金路径模板,讨论如何把工单变成一条可执行、可回滚的自助状态机。

阅读导航:上一节:10.3 密钥、审计与合规边界 · 下一节:11.2 黄金路径与自助服务:从工单到模板 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查