Backstage Primer & Modelling Guide — Developer Portal Catalogue with Linked.Archi¶
Engineering organizations that run Backstage end up with a rich software catalogue locked inside a single tool — hard to query alongside architecture models, hard to govern, and hard to reconcile with ArchiMate or C4. Representing the catalogue as semantic assets makes ownership, API dependencies, and system boundaries queryable across the whole architecture knowledge graph.
This guide introduces the Backstage software catalogue model as formalized in Linked.Archi, explains how its concepts map to semantic assets, and demonstrates practical modelling through a worked example.
Backstage Concepts¶
What is Backstage?¶
Backstage is an open-source developer portal platform originally created by Spotify. Its Software Catalog provides a centralized registry of all software components, systems, APIs, resources, and their ownership within an organization.
Backstage is: - Developer-centric — built for engineering teams to discover and understand services - Ownership-first — every entity has a clear owner (team/group) - API-aware — APIs are first-class citizens with provider/consumer relationships - Domain-driven — entities are grouped into business domains
"Backstage unifies all your infrastructure tooling, services, and documentation to create a streamlined development environment from end to end." — backstage.io
Catalogue Entity Types¶
graph TD
subgraph Software["Software Entities"]
Component["Component"]
System["System"]
API["API"]
Resource["Resource"]
end
subgraph Organisational["Organisational Entities"]
Domain["Domain"]
User["User"]
Group["Group"]
end
Component -->|"partOfSystem"| System
API -->|"partOfSystem"| System
Resource -->|"partOfSystem"| System
Component -->|"providesAPI"| API
Component -->|"consumesAPI"| API
Component -->|"dependsOn"| Component
Component -->|"usesResource"| Resource
Resource -->|"usesResource"| Resource
Component -->|"subcomponentOf"| Component
Component -->|"ownedBy"| Group
System -->|"belongsToDomain"| Domain
Domain -->|"subdomainOf"| Domain
User -->|"memberOf"| Group
Group -->|"childOf"| Group
Template["Template"]
Component -->|"scaffoldedFrom"| Template
Template -->|"ownedBy"| Group
style Software fill:#B3D9FF,stroke:#333,color:#000
style Organisational fill:#D4E6B5,stroke:#333,color:#000
style Template fill:#FFF8E1,stroke:#F57F17,color:#000
| Entity | What it represents |
|---|---|
| Component | A deployable unit — service, library, website, or application |
| System | A set of components that provide business or technical value together |
| API | An interface owned and maintained in the ecosystem (OpenAPI, AsyncAPI, gRPC, GraphQL) |
| Resource | Infrastructure used by components — databases, buckets, queues, external services |
| Domain | A high-level business domain grouping systems and components |
| User | A human user in the ecosystem |
| Group | A team, department, or tribe that owns entities |
| Template | A scaffolder template — the prescribed way to create a component of some type |
Eight of the descriptor format's nine kinds. The ninth, Location, is deliberately not modelled: it
is a marker telling the catalogue where to look for more catalogue data, which is ingestion
configuration rather than a node in an architecture. What it carries is already available from the
other side — bs:managedByLocation and bs:managedByOriginLocation retain the <type>:<target>
reference on every entity a location produced, so "where did this record come from" is answerable
without a Location node. A lift should drop Location entities and report the count.
That decision, and the other deliberate omissions, are written down in the SCOPE STATEMENT
skos:editorialNote on the ontology header. Read it before concluding that a missing term is an
oversight — the bs: namespace is published, so a converter must not mint terms in it, and that rule
only works if a decision is distinguishable from a gap.
Template arrived in 0.5.0, having been out of scope alongside Location until then. The two were
decided together and should not have been. A scaffolder template is the closest thing the catalogue has
to a prescribed, reusable way of building software — the documented purpose of the
backstage.io/source-template annotation is tracking adherence to software standards, and "which
services came from an approved template, and which came from none" is an architecture governance
question. Answering the second half needs the template as a node, not a string.
Relationships¶
The ontology declares 13 relation types, each in the three-declaration form (unqualified property, qualified class, qualified property — see the Relationship Modelling Guide):
| Relationship | From → To | Meaning |
|---|---|---|
| ownedBy | Any Element → Group or User | Entity is owned by a team or, in smaller organisations, a person |
| partOfSystem | Component, API, Resource → System | Entity belongs to a system |
| providesAPI | Component, System → API | Component or system exposes an API |
| consumesAPI | Component, System → API | Component or system depends on an API |
| dependsOn | Component, Resource → Component, Resource | General dependency — spec.dependsOn, deliberately broad |
| usesResource | Component, Resource → Resource | Resource-facing subproperty of dependsOn, kept for continuity |
| belongsToDomain | System → Domain | System belongs to a business domain |
| memberOf | User → Group | User belongs to a team |
| childOf | Group → Group | Group organisational hierarchy, stored child-to-parent |
| subcomponentOf | Component → Component | Component nesting, e.g. a scoring sub-service packaged separately |
| subdomainOf | Domain → Domain | Domain nesting, for organisations with a large domain landscape |
| techdocsDelegatedTo | Any Element → Any Element | Documentation ownership, from the backstage.io/techdocs-entity annotation |
| scaffoldedFrom | Any Element → Template | Entity was created from a scaffolder template, from the backstage.io/source-template annotation |
Two long-standing relations are wider than they first look, because of how the descriptor format
declares the underlying spec field: spec.system is declared on Component, API and Resource,
so all three can sit in a system, and providesAPI/consumesAPI are declared on System as well as
Component — a System is defined as exposing one or several public APIs, so a System source is
ordinary Backstage rather than an edge case.
dependsOn is the general reading of spec.dependsOn and covers component-to-component dependencies
as well as resource dependencies; usesResource is retained as its resource-facing subproperty for
continuity with older data. A lift emitting fresh data should prefer dependsOn and let subproperty
entailment cover usesResource queries.
scaffoldedFrom is the second relation lifted from an annotation rather than a spec field, and the
one place where a literal and an edge deliberately coexist. bs:sourceTemplate keeps the annotation
value the catalogue wrote; scaffoldedFrom is the resolved reference. Emit both where the target
resolves, and only the literal where it does not — an entity whose pull did not include its template
then reads as unresolved rather than as never scaffolded, which is the distinction a standards-
adherence query turns on. It is the same division as bs:kind against rdf:type, and bs:entityRef
against the relations it encodes.
Value Vocabularies¶
Backstage's spec.type fields and lifecycle/visibility states are modelled as named individuals
(DD-23) — classes of individuals with no owl:oneOf, so an adopter can mint their own member in their
own namespace without forking a published axiom:
| Vocabulary | Property | Individuals (documented examples) | Closure |
|---|---|---|---|
bs:LifecycleState |
bs:lifecycleState |
bs:Experimental, bs:Production, bs:DeprecatedState |
Open (Warning if outside the set) |
bs:ApiVisibility |
bs:apiVisibility |
bs:Public, bs:Restricted, bs:Private |
Closed (Violation if outside the set — fixed by the system model) |
bs:StatusLevel |
bs:statusLevel |
bs:InfoLevel, bs:WarningLevel, bs:ErrorLevel |
Closed (Violation — fixed by the descriptor format) |
bs:ComponentType |
bs:componentType |
bs:ServiceType, bs:WebsiteType, bs:LibraryType |
Open |
bs:APIType |
bs:apiType |
bs:OpenAPI, bs:AsyncAPI, bs:GraphQL, bs:GRPC |
Open |
bs:ResourceType |
bs:resourceType |
bs:DatabaseResourceType, bs:S3BucketResourceType, bs:KubernetesClusterResourceType |
Open, illustrative only |
bs:GroupType |
bs:groupType |
bs:TeamGroupType, bs:BusinessUnitGroupType, bs:ProductAreaGroupType, bs:RootGroupType |
Open, illustrative only |
bs:SystemType |
bs:systemType |
bs:ProductSystemType, bs:ServiceSystemType, bs:FeatureSetSystemType |
Open, illustrative only |
bs:DomainType |
bs:domainType |
bs:ProductAreaDomainType, bs:ProductGroupDomainType, bs:BundleDomainType |
Open, illustrative only |
Each individual carries skos:notation with the exact YAML token (e.g. "openapi", "database") —
that is the value a lift matches against when converting catalog-info.yaml to RDF.
bs:componentType is the one vocabulary used by two kinds. On a Component it says what the component
is; on a Template it says what the template creates. That is the descriptor format's own
definition — a template's spec.type "should ideally match the Component spec.type created by the
template" — so one vocabulary serves both ends and bs:ServiceType means the same thing in both
places. The catch to remember when writing a query: joining the two ends means joining a description of
a thing to a description of a thing's output.
bs:StatusLevel members carry the Level suffix because the bare tokens — Info, Warning, Error —
also name SHACL severities, which appear in every shapes file that validates this data. The notation
still carries the plain token.
Entity Status¶
status is a read-only root field. Descriptor files must not contain it, so status items only appear in
a lift of the catalog API, never in a hand-authored model — which is why nothing in the shapes
requires them.
| Term | Role |
|---|---|
bs:StatusItem |
One entry in an entity's status. Not an arch:Element |
bs:hasStatusItem |
Entity → item. Multi-valued |
bs:statusType |
Which system contributed the item, e.g. backstage.io/catalog-processing. Required |
bs:statusLevel |
→ bs:InfoLevel / bs:WarningLevel / bs:ErrorLevel |
bs:statusMessage |
Human-readable, not language-tagged. Do not parse it |
:reporting-service bs:hasStatusItem :reporting-service-status-1 .
:reporting-service-status-1
a bs:StatusItem ;
bs:statusType "backstage.io/catalog-processing" ;
bs:statusLevel bs:ErrorLevel ;
bs:statusMessage "NotFoundError: catalog-info.yaml not found at refs/heads/main" ;
.
A node per item, not fields on the entity. Several systems contribute to status.items
independently, so more than one item is the ordinary case. Flatten them onto the entity and two levels
and two messages arrive with nothing saying which belongs to which — the same argument that makes
bs:Link a node while spec.profile stays flat. bssh:StatusItemPropertiesShape reports the
flattening.
Read an error item the right way round. Where the type is backstage.io/catalog-processing, an
error means the catalogue could not ingest the source and has deliberately kept the last version that
did. The item says the record beside it is stale — not that the record is wrong. Any freshness or
governance query over lifted catalogue data depends on getting that direction right.
status.items[].error, the serialised error object, is deliberately not lifted: its shape is not fixed
upstream and a stack trace is not an architectural fact. Note also that upstream describes the status
model as in active development, so expect this corner of the ontology to move before the rest of it.
Labels and Annotations¶
metadata.labels and metadata.annotations are open key spaces that each organisation defines, so the
ontology publishes the two family super-properties and no members beyond the vendor annotations the
specification itself documents. The intended pattern is one predicate per key, in your own
namespace, parented on the family root:
## Your namespace, not bs:. One predicate per label key.
acme:tier a owl:DatatypeProperty ; rdfs:subPropertyOf bs:label .
acme:pciScope a owl:DatatypeProperty ; rdfs:subPropertyOf bs:label .
:payment-processor acme:tier "gold" ; acme:pciScope "true" .
That keeps both queries cheap: ?e bs:label ?v sweeps every label regardless of key, and
?e acme:tier ?v asks about one. A reified Label node with labelKey/labelValue is explicitly
not the pattern — a label is a single scalar under a single key, so the key is the predicate, and
a node would add a hop carrying nothing while turning a one-triple query into a join. Reification is
reserved for the positional structures, bs:Link and bs:StatusItem, where fields would otherwise be
orphaned from each other.
No SHACL shape enforces this, and the reason is worth knowing rather than guessing at: declaring
acme:tier rdfs:subPropertyOf bs:label makes every correct per-key assertion entail a bs:label
triple too. A shape reporting subjects of bs:label would therefore pass on the malformed graph a
validator ran without inference, and fail on the well-formed one it ran with. The guidance lives in the
document instead, which is where a converter author can act on it. The same applies to
bs:externalIdentifier.
Lifecycle¶
bs:lifecycle(a plain string) is deprecated as of 0.3.0. Usebs:lifecycleState, which points at abs:LifecycleStateindividual —bs:Experimental,bs:Production, orbs:DeprecatedState— instead of a bare string. The old property still validates (with a migration Warning), but new models should usebs:lifecycleStatefrom the start:
Entity Identity¶
A Backstage entity is identified by its entity reference, kind:namespace/name. The identity
fields are retained source data (DD-24) — read from catalog-info.yaml, not computed:
| Field | Property | Required by the shapes |
|---|---|---|
apiVersion |
bs:apiVersion (e.g. "backstage.io/v1alpha1") |
No — maxCount 1 only |
kind |
bs:kind (a plain string, title-case: "Component", "API") |
No — maxCount 1 only |
namespace |
bs:namespace (absent means "default") |
Recommended — Warning if absent |
name |
bs:name |
Yes — Violation if absent |
| the whole reference | bs:entityRef |
No — maxCount 1 only |
:payment-processor
a bs:Component ; # how THIS GRAPH classifies it
bs:apiVersion "backstage.io/v1alpha1" ; # which descriptor schema it was written against
bs:kind "Component" ; # what the CATALOGUE said
bs:namespace "default" ;
bs:name "payment-processor" ;
bs:entityRef "component:default/payment-processor" ; # the reference, as the catalogue circulates it
skos:prefLabel "payment-processor"@en . # the human-readable label
bs:apiVersion is worth retaining per entity rather than assumed constant, because it is not.
Template is the one kind the catalog model does not own — the scaffolder plugin owns it and versions it
separately, on scaffolder.backstage.io/v1beta3, while the other seven kinds are on
backstage.io/v1alpha1. A graph that records the kind and drops the apiVersion loses that, and in a graph
merged from catalogues upgraded at different times it loses more.
Two sources per term, and which one wins. Terms in
bs:cite the rendered page at backstage.io and the file in github.com/backstage/backstage that defines it — the JSON Schemas underpackages/catalog-model/src/schemafor the eight catalog kinds and the envelope, andplugins/scaffolder-commonfor Template. The rendered page is the human account; the schema is what the catalogue enforces. Where they disagree, the schema wins, and the divergence is recorded on the term.Template is the live example. The descriptor format page says
backstage.io/v1beta2and does not mentionspec.lifecycle;Template.v1beta3.schema.jsonadmits onlyscaffolder.backstage.io/v1beta3and declaresspec.lifecycle. Following the page alone would emit a version the scaffolder rejects and drop a field real templates carry.
bs:kind and rdf:type are both recorded, and may diverge¶
They answer different questions. bs:kind is what the descriptor said; rdf:type is how this graph
classifies the entity, which is a modelling decision open to revision later. A lift maps one to the
other, but an adopter may retype a lifted entity under a convention adopted afterwards, or carry
additional types from another notation in a merged graph — and neither makes the retained kind wrong.
Keeping only rdf:type would lose the descriptor's own statement the moment either happened. Keeping
only bs:kind would leave the entity invisible to every sh:targetClass and rdfs:subClassOf query in
the repository. So both are kept, and neither is derived from the other.
The mapping between them is published on the classes, not hardcoded in each lift — each of the eight
element classes carries skos:notation with its exact kind token:
So a lift resolves kind: Component → bs:Component by matching skos:notation, exactly as it
resolves type: openapi → bs:OpenAPI. These tokens are title-case, unlike every other
skos:notation in the ontology, which carries a lowercase spec.type or state token.
Divergence is reported by bssh:KindTypeAlignmentShape at sh:Info — deliberately not an error. A
mis-mapped lift and a deliberate retyping look identical in the data, so the shape makes both visible
for a reconciliation pass rather than pronouncing on which one you meant. Switch it off where retyping
is standing policy.
bs:entityRef is what the catalogue circulates¶
The entity reference is not a convenience we assemble — it is the form Backstage itself uses in the
relations array and in spec.owner, spec.system, spec.dependsOn and the techdocs-entity
annotation. Retaining it verbatim keeps resolution honest: the specification defines the
default-namespace rule and case-insensitive comparison, and a consumer that rebuilds the reference
reimplements those rules and may diverge from the catalogue. A reference the catalogue actually emitted
is evidence; a reconstruction is an assumption. It also makes resolving an incoming reference one triple
pattern instead of a three-way join with string concatenation.
bssh:EntityRefConsistencyShape checks it against bs:kind, bs:namespace and bs:name — the three
other retained fields from the same descriptor, so a mismatch means the lift mangled one of them, which
is a genuine Violation. It is not checked against rdf:type, because validating the reference
against a type the modeller is free to change would report every intentionally retyped entity as broken.
Both bs:kind and bs:entityRef are targeted with sh:targetSubjectsOf, so a natively authored model
that has no descriptor to retain simply omits them and is not reported as incomplete — as the
hand-written PayFlow example does.
Two things not to do¶
bs:name does not substitute for skos:prefLabel — emit both. And bs:title is presentation-only;
putting it in skos:prefLabel in place of the name makes two entities sharing a title indistinguishable
in every label-driven view.
Do not use bs:uid as a join key. The catalog assigns it on first ingestion and the specification warns
it changes when the identical file is unregistered and re-registered, so it identifies a registration
rather than the thing. The entity reference is what joins across sources.
Semantic Assets¶
Asset Set¶
modelingLanguages/backstage/
├── backstage-metamodel.ttl ← Entry point: arch:Metamodel
├── backstage-onto.ttl ← OWL ontology (8 elements, 13 relationships, 9 value vocabularies)
├── backstage-tax.ttl ← SKOS taxonomy
├── backstage-shapes.ttl ← SHACL validation
├── backstage-notation.ttl ← Visual notation set (one descriptor per element class)
├── backstage-viewpoints.ttl ← 7 viewpoints
├── backstage-reference-data.ttl ← Cross-module correspondences (lifecycle ↔ arch-processes stages)
├── backstage-presentation-contexts.ttl ← Developer, Architect, Stakeholder themes
└── backstage-deliverable-templates.ttl ← Document templates
The value vocabularies themselves — lifecycle, API visibility, component/API/resource/group/system/domain
types — moved from backstage-reference-data.ttl into backstage-onto.ttl as of 0.3.0, as named
individuals (DD-23), the same pattern UML uses for uml:MessageSort. backstage-reference-data.ttl now
holds only what sits around those values — correspondences to other vocabularies (e.g.
bs:Production skos:closeMatch ap:Active, aligning Backstage lifecycle to the notation-agnostic
Architecture Processes extension) and any organisation-specific reference data an adopter adds. Two
IRIs for one value was the failure mode this reconciliation removed.
Concept-to-Asset Mapping¶
| Backstage Concept | Semantic Representation | File |
|---|---|---|
| Entity types (Component, System, etc.) | owl:Class rdfs:subClassOf arch:Element |
backstage-onto.ttl |
| Relationships (ownedBy, providesAPI, dependsOn, childOf, etc.) | Three-declaration pattern | backstage-onto.ttl |
| Lifecycle, visibility, component/API/resource/group/system/domain types | owl:NamedIndividual (DD-23, no owl:oneOf) |
backstage-onto.ttl |
status.items[] |
bs:StatusItem node per item (not an arch:Element) |
backstage-onto.ttl |
metadata.links[] |
bs:Link node per link (not an arch:Element) |
backstage-onto.ttl |
metadata.labels / metadata.annotations |
Per-key subproperty of bs:label / bs:externalIdentifier, in your namespace |
your own vocabulary |
| Cross-vocabulary correspondences (e.g. lifecycle ↔ arch-processes stages) | skos:closeMatch |
backstage-reference-data.ttl |
| Validation (identity, naming, relationship domain/range, status items, property placement, vocabulary closure) | sh:NodeShape |
backstage-shapes.ttl |
| Viewpoints | arch:Viewpoint |
backstage-viewpoints.ttl |
The Location kind, spec.parameters/spec.steps, status.items[].error |
Deliberately nothing — see the SCOPE STATEMENT on the ontology header | — |
Cross-Language Integration¶
Backstage entities map naturally to ArchiMate and C4:
| Backstage | ArchiMate 4.0 | C4 |
|---|---|---|
bs:Component |
am4:ApplicationComponent |
c4:Container or c4:Component |
bs:System |
(group of components) | c4:SoftwareSystem |
bs:API |
am4:ApplicationInterface |
— |
bs:Resource |
am4:Node / am4:Artifact |
c4:DeploymentNode |
bs:Group |
am4:Role |
— |
Worked Example: PayFlow (Fintech Platform)¶
Prerequisites¶
core/core-onto.ttl
modelingLanguages/backstage/backstage-onto.ttl
modelingLanguages/backstage/backstage-tax.ttl
modelingLanguages/backstage/backstage-shapes.ttl
modelingLanguages/backstage/backstage-viewpoints.ttl
modelingLanguages/backstage/backstage-reference-data.ttl
modelingLanguages/backstage/backstage-metamodel.ttl
About PayFlow¶
PayFlow is a fintech platform providing payment processing, merchant onboarding, and financial reporting as a service. The engineering organization is structured into domain-aligned squads using Backstage as their developer portal.
The complete, validatable example model is available at examples/payflow/payflow-model.ttl.
Model File Header¶
Start every Backstage model with this header, which binds it to the Backstage metamodel:
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix arch: <https://meta.linked.archi/core#> .
@prefix bs: <https://meta.linked.archi/backstage/onto#> .
@prefix : <https://meta.linked.archi/examples/payflow/> .
:PayFlowModel
a arch:Model ;
skos:prefLabel "PayFlow — Backstage Catalog Model"@en ;
arch:modelConformsToMetamodel <https://meta.linked.archi/backstage/metamodel#Backstage> ;
dcterms:created "2025-06-27"^^xsd:date ;
.
The model file also needs
@prefix xsd:for the date literal above; the full header is in the example file.
Element Patterns¶
Paste these patterns to declare each catalogue entity with its identity, type, and label, then rename
them to match your estate. Every entity carries bs:name (the catalog identity, required by
BackstageEntityIdentityShape) alongside skos:prefLabel (the human-readable label):
## Domains
:PaymentsDomain a bs:Domain ; bs:name "payments" ; skos:prefLabel "Payments"@en .
:MerchantDomain a bs:Domain ; bs:name "merchant-services" ; skos:prefLabel "Merchant Services"@en .
:PlatformDomain a bs:Domain ; bs:name "platform" ; skos:prefLabel "Platform"@en .
## Groups (teams) — spec.type is optional but recommended
:PaymentsSquad a bs:Group ; bs:name "payments-squad" ; bs:groupType bs:TeamGroupType ; skos:prefLabel "Payments Squad"@en .
:MerchantSquad a bs:Group ; bs:name "merchant-squad" ; bs:groupType bs:TeamGroupType ; skos:prefLabel "Merchant Squad"@en .
:PlatformTeam a bs:Group ; bs:name "platform-team" ; bs:groupType bs:TeamGroupType ; skos:prefLabel "Platform Team"@en .
:SRETeam a bs:Group ; bs:name "sre-team" ; bs:groupType bs:TeamGroupType ; skos:prefLabel "SRE Team"@en .
## Group hierarchy — spec.parent, stored child-to-parent
:SRETeam bs:childOf :PlatformTeam .
## Systems
:PaymentGateway a bs:System ; bs:name "payment-gateway" ; skos:prefLabel "Payment Gateway"@en .
:MerchantPortal a bs:System ; bs:name "merchant-portal" ; skos:prefLabel "Merchant Portal"@en .
:ObservabilityPlatform a bs:System ; bs:name "observability-platform" ; skos:prefLabel "Observability Platform"@en .
## Components — bs:lifecycleState points at a bs:LifecycleState individual, not a string
:PaymentProcessor a bs:Component ; bs:name "payment-processor" ; skos:prefLabel "payment-processor"@en ; bs:lifecycleState bs:Production .
:FraudService a bs:Component ; bs:name "fraud-service" ; skos:prefLabel "fraud-service"@en ; bs:lifecycleState bs:Production .
:PayoutService a bs:Component ; bs:name "payout-service" ; skos:prefLabel "payout-service"@en ; bs:lifecycleState bs:Production .
:MerchantOnboarding a bs:Component ; bs:name "merchant-onboarding" ; skos:prefLabel "merchant-onboarding"@en ; bs:lifecycleState bs:Production .
:ReportingService a bs:Component ; bs:name "reporting-service" ; skos:prefLabel "reporting-service"@en ; bs:lifecycleState bs:Experimental .
:WebhookDispatcher a bs:Component ; bs:name "webhook-dispatcher" ; skos:prefLabel "webhook-dispatcher"@en ; bs:lifecycleState bs:Production .
## Component nesting — spec.subcomponentOf, a component packaged and shipped separately from its parent
:FraudModelScorer a bs:Component ; bs:name "fraud-model-scorer" ; skos:prefLabel "fraud-model-scorer"@en ;
bs:lifecycleState bs:Experimental ; bs:subcomponentOf :FraudService .
## APIs — bs:apiType points at a bs:APIType individual
:PaymentsAPI a bs:API ; bs:name "payments-api" ; bs:apiType bs:OpenAPI ; skos:prefLabel "Payments API (OpenAPI)"@en .
:MerchantAPI a bs:API ; bs:name "merchant-api" ; bs:apiType bs:OpenAPI ; skos:prefLabel "Merchant API (OpenAPI)"@en .
:PaymentEventsAPI a bs:API ; bs:name "payment-events" ; bs:apiType bs:AsyncAPI ; skos:prefLabel "Payment Events (AsyncAPI)"@en .
:FraudScoringAPI a bs:API ; bs:name "fraud-scoring" ; bs:apiType bs:GRPC ; skos:prefLabel "Fraud Scoring API (gRPC)"@en .
## Resources — bs:resourceType is illustrative-only; mint your own individual where the
## documented examples (database, s3-bucket, kubernetes-cluster) don't fit
:PaymentsDB a bs:Resource ; bs:name "payments-db" ; bs:resourceType bs:DatabaseResourceType ; skos:prefLabel "payments-db (PostgreSQL)"@en .
:EventBus a bs:Resource ; bs:name "event-bus" ; bs:resourceType :MessageBrokerResourceType ; skos:prefLabel "event-bus (Kafka)"@en .
:MerchantDB a bs:Resource ; bs:name "merchant-db" ; bs:resourceType bs:DatabaseResourceType ; skos:prefLabel "merchant-db (PostgreSQL)"@en .
:RedisCache a bs:Resource ; bs:name "redis-cache" ; bs:resourceType :CacheResourceType ; skos:prefLabel "redis-cache"@en .
## Organisation-specific bs:ResourceType individuals — message-broker and cache have no
## documented example, so PayFlow mints them in its own namespace
:MessageBrokerResourceType a bs:ResourceType ; skos:prefLabel "Message Broker"@en ; skos:notation "message-broker" .
:CacheResourceType a bs:ResourceType ; skos:prefLabel "Cache"@en ; skos:notation "cache" .
Relationship Patterns¶
Wire the entities together with ownership, system membership, API dependencies, and general dependency:
## Ownership
:PaymentProcessor bs:ownedBy :PaymentsSquad .
:FraudService bs:ownedBy :PaymentsSquad .
:PayoutService bs:ownedBy :PaymentsSquad .
:MerchantOnboarding bs:ownedBy :MerchantSquad .
:ReportingService bs:ownedBy :MerchantSquad .
:WebhookDispatcher bs:ownedBy :PlatformTeam .
:PaymentGateway bs:ownedBy :PaymentsSquad .
:MerchantPortal bs:ownedBy :MerchantSquad .
## System membership
:PaymentProcessor bs:partOfSystem :PaymentGateway .
:FraudService bs:partOfSystem :PaymentGateway .
:PayoutService bs:partOfSystem :PaymentGateway .
:WebhookDispatcher bs:partOfSystem :PaymentGateway .
:MerchantOnboarding bs:partOfSystem :MerchantPortal .
:ReportingService bs:partOfSystem :MerchantPortal .
## API provision
:PaymentProcessor bs:providesAPI :PaymentsAPI .
:PaymentProcessor bs:providesAPI :PaymentEventsAPI .
:FraudService bs:providesAPI :FraudScoringAPI .
:MerchantOnboarding bs:providesAPI :MerchantAPI .
## API consumption
:PayoutService bs:consumesAPI :PaymentsAPI .
:MerchantOnboarding bs:consumesAPI :PaymentsAPI .
:WebhookDispatcher bs:consumesAPI :PaymentEventsAPI .
:PaymentProcessor bs:consumesAPI :FraudScoringAPI .
## General dependency — spec.dependsOn between two Components (the reading usesResource
## does not cover, since usesResource's target is always a Resource)
:ReportingService bs:dependsOn :PaymentProcessor .
## Resource usage — the resource-facing subproperty of dependsOn
:PaymentProcessor bs:usesResource :PaymentsDB .
:PaymentProcessor bs:usesResource :EventBus .
:PaymentProcessor bs:usesResource :RedisCache .
:FraudService bs:usesResource :RedisCache .
:MerchantOnboarding bs:usesResource :MerchantDB .
:WebhookDispatcher bs:usesResource :EventBus .
## Domain membership — spec.domain is declared on System only
:PaymentGateway bs:belongsToDomain :PaymentsDomain .
:MerchantPortal bs:belongsToDomain :MerchantDomain .
:ObservabilityPlatform bs:belongsToDomain :PlatformDomain .
Templates and Scaffolding Provenance¶
A Template is an ordinary catalogue entity: it has a name, an owner, and a type. What makes it worth modelling is the edge from the things it produced.
## Note the apiVersion: the scaffolder plugin owns this kind, so it is v1beta3 and
## carries the scaffolder. prefix — per Template.v1beta3.schema.json, not the docs page.
## spec.type is bs:componentType: it names the component this template CREATES.
## spec.lifecycle is in the schema, though the rendered page omits it.
:NewServiceTemplate
a bs:Template ;
bs:apiVersion "scaffolder.backstage.io/v1beta3" ;
bs:kind "Template" ;
bs:name "new-payment-service" ;
bs:componentType bs:ServiceType ;
bs:lifecycleState bs:Production ;
bs:timeSaved "PT6H"^^xsd:duration ; # backstage.io/time-saved, ISO 8601
skos:prefLabel "new-payment-service"@en ;
skos:definition "Scaffolder template for a new payment-domain backend service."@en ;
.
:NewServiceTemplate bs:ownedBy :PlatformTeam .
## Both forms, deliberately: the retained annotation literal and the resolved edge.
:PayoutService
bs:sourceTemplate "template:default/new-payment-service" ;
bs:scaffoldedFrom :NewServiceTemplate ;
.
## The literal alone, where the pull did not include the template. Reads as
## "scaffolded from something we cannot see", not "never scaffolded".
:FraudService
bs:sourceTemplate "template:default/legacy-ml-service" ;
.
bs:timeSaved is an xsd:duration, not a string, because the specification fixes the format as ISO
8601 — so it is summable as it stands. It is declared on the Template, never copied onto the entities
the template produced: the estimate belongs to the template, and
bssh:TemplateOnlyPropertiesShape reports the copy. Total time saved is the count of
bs:scaffoldedFrom edges multiplied by the template's estimate, and neither factor existed before 0.5.0.
The governance query the pair exists for — components that came from no approved template:
PREFIX bs: <https://meta.linked.archi/backstage/onto#>
SELECT ?component WHERE {
?component a bs:Component .
FILTER NOT EXISTS { ?component bs:scaffoldedFrom ?anyTemplate }
}
Status Items¶
Only present in a lift of the catalog API — the hand-authored parts of this example carry none, which is why the shapes never require them.
## Two items on one entity, from two different systems. This is the case that makes
## a node per item necessary rather than merely tidy.
:PaymentProcessor bs:hasStatusItem :PaymentProcessorStatus-1, :PaymentProcessorStatus-2 .
:PaymentProcessorStatus-1
a bs:StatusItem ;
bs:statusType "backstage.io/catalog-processing" ;
bs:statusLevel bs:InfoLevel ;
bs:statusMessage "Entity refreshed from url:https://git.example.com/payflow/payment-processor" ;
.
:PaymentProcessorStatus-2
a bs:StatusItem ;
bs:statusType "example.com/slo-checker" ;
bs:statusLevel bs:WarningLevel ;
bs:statusMessage "Error budget for the current window is 8% remaining" ;
.
Finding the records the catalogue could not refresh — the entities whose data is stale rather than wrong:
PREFIX bs: <https://meta.linked.archi/backstage/onto#>
SELECT ?entity ?message WHERE {
?entity bs:hasStatusItem ?item .
?item bs:statusType "backstage.io/catalog-processing" ;
bs:statusLevel bs:ErrorLevel ;
bs:statusMessage ?message .
}
Labels¶
PayFlow's own label keys, as predicates PayFlow owns:
:tier a owl:DatatypeProperty ; rdfs:subPropertyOf bs:label .
:pciScope a owl:DatatypeProperty ; rdfs:subPropertyOf bs:label .
:PaymentProcessor :tier "gold" ; :pciScope "true" .
:ReportingService :tier "bronze" ; :pciScope "false" .
Nothing is minted in bs:, and ?e bs:label ?v still sweeps every label on every entity.
Service Catalogue View¶
graph TD
subgraph PaymentsDomain["Payments Domain"]
subgraph PG["Payment Gateway (System)"]
PP["payment-processor<br/>Production"]
FS["fraud-service<br/>Production"]
PS["payout-service<br/>Production"]
WD["webhook-dispatcher<br/>Production"]
end
end
subgraph MerchantDomain["Merchant Services Domain"]
subgraph MP["Merchant Portal (System)"]
MO["merchant-onboarding<br/>Production"]
RS["reporting-service<br/>Experimental"]
end
end
PP -->|"provides"| PAPI["Payments API"]
PP -->|"provides"| PEAPI["Payment Events"]
FS -->|"provides"| FSAPI["Fraud Scoring API"]
MO -->|"provides"| MAPI["Merchant API"]
PS -->|"consumes"| PAPI
MO -->|"consumes"| PAPI
WD -->|"consumes"| PEAPI
PP -->|"consumes"| FSAPI
PP -->|"uses"| PDB["payments-db"]
PP -->|"uses"| EB["event-bus"]
MO -->|"uses"| MDB["merchant-db"]
style PaymentsDomain fill:#B3D9FF,stroke:#333,color:#000
style MerchantDomain fill:#D4E6B5,stroke:#333,color:#000
style PG fill:#E3F2FD,stroke:#333,color:#000
style MP fill:#E8F5E9,stroke:#333,color:#000
Validate the Catalogue with SHACL¶
Run this to check the model against the core and Backstage shapes. The ontology is passed alongside
the model as data, not only as a shapes document: the model's bs:resourceType/bs:groupType/etc.
values are individuals declared in backstage-onto.ttl (bs:DatabaseResourceType,
bs:TeamGroupType, ...), and the sh:class constraints on those properties need that typing
present in the validated graph, not only in the shapes graph:
As of 0.5.0 this is the registered backstage profile, so the short form is enough:
The long form, if you are validating your own catalogue rather than the example:
.scripts/validate.sh --shacl \
"modelingLanguages/backstage/backstage-onto.ttl,your-catalogue.ttl" \
core/core-shapes.ttl \
core/core-onto.ttl \
modelingLanguages/backstage/backstage-onto.ttl \
modelingLanguages/backstage/backstage-shapes.ttl
Two mechanics that are easy to get wrong and produce confusing output:
- The ontology goes in both positions. As a shapes document, because RDF4J's
ShaclSailreadsrdfs:subClassOffrom the shapes graph for its subclass reasoning — without it,sh:class arch:Elementdoes not resolve for abs:Componentand the relationship endpoint checks fail on correct data. As data, for the reason above.owl:importsdoes not substitute: RDF4J does not follow it. - The ontology must come first in the data list.
ShaclValidatorcommits each data file in its own transaction and validation runs per commit, so a vocabulary loaded after the model that references it is not yet visible. Get the order wrong and every correctbs:DatabaseResourceTypereference is reported at once.
PayFlow is the regression fixture for the whole asset set, and the backstage profile fails if it stops
passing. It carries bs:name on every entity, bs:lifecycleState rather than the deprecated string, a
proper vocabulary individual for every spec.type (two of them — message-broker and cache — minted
locally, since Backstage's bs:ResourceType examples don't cover them), and since 0.5.0 two Templates,
scaffolding edges in both the plain and qualified form, an unresolved bs:sourceTemplate with no edge,
four status items, and PayFlow-owned label predicates. It validates clean — no Violations and no Warnings.
Until 0.5.0 the profile validated the ontology as its data graph, and passed while checking almost nothing: entity identity, naming, endpoints, value-set membership and property placement all target instances, and the ontology contains none. Pointing it at PayFlow found real findings on data that had been considered good. If you add a profile, pair the shapes with an example.
Further Reading¶
- Modelling Languages Guide — how Backstage relates to ArchiMate, C4, and other languages
- Backstage as C4 Complement or Replacement — positioning guide
- Deployment Modelling: ArchiMate Technology vs C4/Backstage — deployment modelling comparison
- Build Your Own Modelling Tool — how tools consume these assets
- Validation Guide — SHACL validation pipeline
Official Backstage sources¶
Everything in bs: derives from these. Rendered documentation first, then the repository artifacts that
define the same things in machine-readable form — and which take precedence where the two disagree.
Documentation — backstage.io
| Page | What it defines |
|---|---|
| Software Catalog | The catalogue as a whole |
| System Model | Component, System, API, Resource, Domain, User, Group and how they relate |
| Descriptor Format | The envelope, the reserved metadata fields, status, and every kind's spec |
| Well-known Relations | ownedBy, partOf, dependsOn, providesApi, memberOf, parentOf and their inverses |
| Well-known Annotations | The backstage.io/* and vendor annotation keys |
| Well-known Statuses | status.items[].type values |
| Software Templates | The Scaffolder, which owns the Template kind |
| Entity References | kind:namespace/name and the comparison rules |
Repository — github.com/backstage/backstage
| Path | What it defines |
|---|---|
packages/catalog-model/src/schema |
JSON Schemas for Entity, EntityEnvelope, EntityMeta |
.../schema/kinds |
One schema per catalog kind, including Location.v1alpha1 |
packages/catalog-model |
The reference implementation of the model |
plugins/scaffolder-common |
Template.v1beta3.schema.json and TemplateEntityV1beta3.ts |
docs/features/software-catalog |
Markdown source of the pages above |
LICENSE |
Apache-2.0, under which the quoted definitions are published |
Also: Backstage on CNCF — the project is a CNCF incubating project, donated by Spotify — and backstage/community.
Content from these sources was paraphrased or quoted in short form for identification and alignment;
see dcterms:rightsHolder and dcterms:rights on the ontology header.
Comparison Articles¶
For detailed comparisons of Backstage with other modelling languages in Linked.Archi:
- Backstage as C4 Complement or Replacement — When to use Backstage alongside or instead of C4
- Deployment Modelling: ArchiMate Technology vs C4/Backstage — Deployment modelling across notations
- EA Frameworks Compared — Broader comparison across TOGAF, Zachman, EA on a Page, and others
Disclaimer: This is a community semantic representation of the Backstage software catalogue model for interoperability purposes. It is not produced by, endorsed by, or affiliated with Spotify or the Backstage project. "Backstage" is a trademark of Spotify AB.