Skip to content

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. Use bs:lifecycleState, which points at a bs:LifecycleState individual — bs:Experimental, bs:Production, or bs:DeprecatedState — instead of a bare string. The old property still validates (with a migration Warning), but new models should use bs:lifecycleState from the start:

# Before (0.2.0, deprecated)
:payment-processor bs:lifecycle "production" .

# After (0.3.0)
:payment-processor bs:lifecycleState bs:Production .

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 under packages/catalog-model/src/schema for the eight catalog kinds and the envelope, and plugins/scaffolder-common for 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/v1beta2 and does not mention spec.lifecycle; Template.v1beta3.schema.json admits only scaffolder.backstage.io/v1beta3 and declares spec.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:

bs:Component skos:notation "Component" .
bs:API       skos:notation "API" .

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:

.scripts/validate.sh --shacl backstage

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 ShaclSail reads rdfs:subClassOf from the shapes graph for its subclass reasoning — without it, sh:class arch:Element does not resolve for a bs:Component and the relationship endpoint checks fail on correct data. As data, for the reason above. owl:imports does not substitute: RDF4J does not follow it.
  • The ontology must come first in the data list. ShaclValidator commits 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 correct bs:DatabaseResourceType reference 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

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:


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.