Architectural Design
Declared vs. Implemented Architecture
Declared architecture — the PlantUML diagrams passed to
architectural_design(static,dynamic,public_api,internal_api). This is what your architecture is supposed to look like: the components, units, and interfaces you intend to build, and how they should relate to each other.Implemented architecture — the actual Bazel targets that get compiled and tested:
unit(implementation = [...])wraps the real source files,component(components = [...])groups those units, anddependable_element(components = [...])assembles the complete SEooC. This is what your architecture actually is.
Because these two views are authored independently, they can drift apart. Therefore rules_score implements an automatic architecture consistency check that runs at bazel build/bazel test time: every component or unit that appears in dependable_element.components must also appear, under the same name, in the static PlantUML diagram declared via architectural_design.static — and vice versa. A mismatch fails the build. See General Information for the full list of automatic validations rules_score performs.
Overview and Hierarchy
Software in rules_score is structured in three levels:
dependable_element (SEooC — complete Safety Element out of Context)
└── component (groups units; owns component-level integration tests and requirements)
├── unit (smallest independently verifiable architectural element: implementation + unit tests)
└── component (components can be nested for deeper hierarchies)
└── unit
Two rules apply:
unittargets must always be wrapped in acomponent— they cannot be placed directly underdependable_element.componenttargets can be nested: a component may contain other components as well as units, allowing arbitrary depth.
Below the different levels there are multiple views which present the architecture from different perspectives:
Static — the structural organisation: which components and units exist, how they nest, and how they depend on each other. Validated against the Bazel model at build time.
Dynamic — behavioural sequences, state transitions, and activity flows. Documentation only, not validated against Bazel targets.
Public API — the interfaces the SEooC exposes to its environment, linked to safety analysis via
FailureMode.interface.Internal API — interfaces exposed between components inside the SEooC that are not part of the public boundary.
Determining Components and Units
The Bazel rules and the consistency check only verify that your declared and
implemented structure match — they cannot tell you whether the structure is
good. Deciding what becomes a component and what becomes a unit is a
design activity.
Start from the requirements and the public interface, then decompose top-down:
the dependable_element is fixed by the SEooC boundary (its public API), and
you refine it into components and units until every leaf is small enough to be
implemented and tested by one owner.
What makes a unit
A unit is the smallest architectural element that is independently verifiable. Model something as a unit when it satisfies all of the following:
Single responsibility — it does one thing; you can state its purpose in a single sentence without using “and”.
Independently verifiable — its behaviour can be fully covered by unit tests through a narrow interface, without standing up the rest of the SEooC.
Cohesive implementation — its source files (the
cc_librarybehindunit.implementation) change together and share the same data.One owner — a single team/person is responsible for its design and tests.
Backed by a unit design — its internal class structure is documented and validated against the code via Software Unit Design.
If a unit’s class diagram grows several unrelated clusters of classes, or its unit tests split into groups that never share fixtures, it is really two units.
What makes a component
A component groups units (and possibly sub-components) that collaborate to deliver a coherent piece of feature behaviour. Introduce a component when:
Several units together realise one feature or provide one internal interface to the rest of the SEooC.
The grouping owns behaviour that only emerges from unit interaction — captured by component-level integration tests and component requirements (
CompReq).It gives you a stable boundary you can allocate requirements to and reason about in the safety analysis.
Nest a component inside another component only when the inner grouping has its own meaningful interface and requirements; do not nest purely to mirror source folders.
Deciding the boundaries
Use these heuristics — most are the classic high-cohesion / low-coupling
rules applied to the rules_score element levels:
Cohesion first — put things that change together and share data in the same element; split things that change for different reasons.
Minimise the interface — prefer a decomposition that yields the fewest, narrowest interfaces between elements. A boundary that needs a wide, chatty interface is usually in the wrong place.
Follow the requirement allocation — a
CompReqis allocated to exactly one component. If a candidate requirement naturally splits across two groups, that is a component boundary; if it lands entirely inside one group, keep it together.Match the failure-containment goal — a component/unit boundary is also a boundary for the safety analysis. Draw boundaries so that a failure can be argued about, and a control measure placed, at a single element (see Safety Analysis).
Keep units testable in isolation — if you cannot unit-test a candidate unit without a second unit present, either merge them or introduce an interface (internal API) so the dependency can be substituted.
Public vs. internal interfaces
An interface is public API when it is part of the SEooC’s contract with its environment — it is bound from the
<<SEooC>>element and feedsFailureMode.interfacetraceability. Keep the public API as small as the requirements allow; every public method is a contract with the user and a safety-analysis entry point.An interface is internal API when it exists only between components/units inside the SEooC. Model it inside the owning element’s namespace. Promote an interface from internal to public only when an external requirement forces it.
Common anti-patterns
Folder-driven decomposition — creating a component per source directory instead of per feature/interface. Structure follows responsibility, not layout.
God unit — one unit that accumulates unrelated responsibilities because it was the first one created. Split as soon as a second responsibility appears.
Anaemic component — a component that only forwards calls and owns no integration tests or requirements. Either give it a real boundary or flatten it.
Leaky public API — exposing an interface publicly for convenience. It then drags in unnecessary failure modes and AoUs.
Static Architecture
The static view describes the structural organisation of your software: what components and units exist, how they relate to each other, and which dependencies they carry. It is the primary input for the architecture consistency check.
PlantUML
Write a PlantUML class or component diagram that names every component and unit from your Bazel BUILD file.
@startuml SeoocExample_StaticDesign
package safety_software_seooc_example <<SEooC>> {
component component_example <<component>> {
component unit_1 <<unit>>
component unit_2 <<unit>>
component sub_component_example <<component>>
interface InternalInterface
unit_1 -l-( InternalInterface
unit_2 -r- InternalInterface
}
}
interface SampleLibraryAPI
safety_software_seooc_example )-d- SampleLibraryAPI
@enduml
Valid PlantUML Definitions
The validator identifies elements by their stereotype, not by the PlantUML keyword used. Both package and component keywords are accepted at each level.
Stereotype |
Valid PlantUML keywords |
Meaning |
Bazel rule |
|---|---|---|---|
|
|
Safety Element out of Context boundary |
|
|
|
Architectural component |
|
|
|
Leaf implementation unit |
|
Interface Bindings
Any component-type element (<<SEooC>>, <<component>>, or <<unit>>) can bind directly to an interface using the lollipop syntax — also a dedicated port can be drawn.
@startuml static_design
package safety_software_seooc_example <<SEooC>> {
component component_example <<component>> {
component unit_1 <<unit>>
component unit_2 <<unit>>
component sub_component_example <<component>>
interface InternalInterface
unit_1 -l-( InternalInterface
unit_2 -r- InternalInterface
}
}
interface SampleLibraryAPI
safety_software_seooc_example )-d- SampleLibraryAPI
@enduml
Named Ports (alternative)
When an element needs an explicitly named, standalone binding point — for example to distinguish multiple provided interfaces without attaching them to a specific child unit — declare a portin / portout inside the <<SEooC>> or <<component>> element instead of binding directly on a child element:
@startuml SeoocExample_StaticDesign
package safety_software_seooc_example <<SEooC>> {
component component_example <<component>> {
component unit_1 <<unit>>
}
portin p_required ' required interface port
portout p_public ' provided interface port
}
interface RequiredInterface
interface SampleLibraryAPI
p_required -( RequiredInterface : requires
p_public )- SampleLibraryAPI : provides
@enduml
Splitting Into an Overview and Detail Diagrams
For a large architecture, pass more than one file to static: one shared
overview diagram that bare-declares the SEooC boundary and its top-level
components, plus one or more detail diagrams that elaborate a component’s
internals. All files passed to static are resolved together: a
top-level element with a body in a detail file that matches a nested
declaration in the overview file (same name, element type and stereotype) is
implicitly anchored onto it, so the detail file needs no wrapper and no
repeated interfaces/relations:
' overview_design.puml
@startuml overview_design
package safety_software_seooc_example <<SEooC>> {
component component_example <<component>>
}
interface SampleLibraryAPI
safety_software_seooc_example )-d- SampleLibraryAPI
@enduml
' static_design.puml
@startuml static_design
component component_example <<component>> {
component unit_1 <<unit>>
component unit_2 <<unit>>
}
@enduml
architectural_design(
name = "my_arch",
static = ["overview_design.puml", "static_design.puml"],
)
See examples/seooc/design for the full working pair.
Limitations:
Exactly one file must be the “home” for a given parent’s full set of children — one diagram declares all of a parent’s children; the others may only bare-declare that parent (no children) or repeat that same full set. Two files each declaring a different, incomplete subset of the same parent’s children is rejected as a merge error — in practice, use one shared overview file plus one or more detail files that each own a disjoint part of the hierarchy, not several files partially detailing the same component.
Anchoring only fires for a top-level element with a body that matches exactly one other file’s nested declaration (same name, element type, and stereotype); a bodyless re-declaration stays a separate top-level entity, and a match under more than one distinct parent across the file set is an ambiguity error rather than a guess.
A relation’s source must be declared (or anchored) in the file that writes it; only its target may resolve to another file in the same
staticgroup.
See Multiple static PlantUML files for the complete merge rules.
The same split works in every other view. Each view is resolved on its own, so
ids are never shared between static, dynamic, public_api and
internal_api:
Class diagrams (
public_api,internal_api): an overview nests a class without a body, a detail file declares it with its members; the detail file is anchored the same way as for components. Write theclasskeyword in every file, otherwise a file may be detected as a component diagram.Sequence diagrams (
dynamic): one file declares the participants (the cast), the scenario files use only the aliases and anactivateso they are detected as sequence diagrams.Activity diagrams have no names to share.
' cast.puml
@startuml
participant "logging::Recorder::Backend" as Backend
@enduml
' scenario.puml
@startuml
Client -> Backend : Write()
activate Backend
@enduml
See Multiple files in the other views for the rules and error messages.
Bazel
architectural_design
load("@score_tooling//bazel/rules/rules_score:rules_score.bzl", "architectural_design")
architectural_design(
name = "my_arch",
static = ["static_design.puml"], # the static diagram above
dynamic = ["sequence_design.puml"],
)
static accepts more than one .puml file — see
Splitting Into an Overview and Detail Diagrams above for splitting a large architecture
into an overview plus detail diagrams, and their limitations.
unit
load("@score_tooling//bazel/rules/rules_score:rules_score.bzl", "unit")
# Unit 1
cc_library(name = "unit_1_lib", srcs = ["foo.cpp"], hdrs = ["foo.h"])
cc_test (name = "unit_1_test", srcs = ["foo_test.cpp"], deps = [":unit_1_lib"])
unit(
name = "unit_1",
unit_design = ["//unit_1/docs:unit_design"],
implementation = [":unit_1_lib"],
tests = [":unit_1_test"],
)
# Unit 2
cc_library(name = "unit_2_lib", srcs = ["bar.cpp"], hdrs = ["bar.h"])
cc_test (name = "unit_2_test", srcs = ["bar_test.cpp"], deps = [":unit_2_lib"])
unit(
name = "unit_2",
unit_design = ["//unit_2/docs:unit_design"],
implementation = [":unit_2_lib"],
tests = [":unit_2_test"],
)
component
load("@score_tooling//bazel/rules/rules_score:rules_score.bzl",
"component", "component_requirements")
component_requirements(
name = "component_requirements",
srcs = ["component_requirements.trlc"],
deps = [":feature_requirements"],
)
# The component maps to ComponentExample in the PlantUML diagram
component(
name = "component_example",
requirements = [":component_requirements"],
components = [":unit_1", ":unit_2"],
tests = [],
)
Decoupling the Bazel Target Name from the Diagram Alias
By default the consistency check matches a unit/component against the
PlantUML diagram by its Bazel target name. Pass design_name to match
against a different alias instead — useful when the Bazel target name must
follow a repo-wide naming convention that differs from the architecture diagram:
unit(
name = "unit_1_impl",
design_name = "unit_1", # matches "unit_1" in the PlantUML diagram
unit_design = ["//unit_1/docs:unit_design"],
implementation = [":unit_1_lib"],
tests = [":unit_1_test"],
)
Dynamic Architecture
The dynamic view describes behavioural aspects — sequences of interactions, state transitions, and activity flows. Dynamic diagrams document how your software behaves at runtime. They are not validated against the Bazel structure at build time.
PlantUML
@startuml SeoocExample_DynamicDesign
participant unit_1 <<unit>>
participant unit_2 <<unit>>
unit_1 -> unit_2 : GetData()
return Data*
@enduml
Bazel
architectural_design(
name = "my_arch",
static = ["static_design.puml"],
dynamic = ["sequence.puml"],
)
Public API
The public API view describes the interface your SEooC exposes to its environment. They define a clear interface for the user of the dependable element and state which functions of the dependable element are carrying the safety related information.
As a proof for their safety relevance for each public method a FMEA should be carried out. This is documented by linking each method to a safety analysis:
FailureMode records reference interface items by name (via the interface field), enabling traceability from each failure mode back to the architecture.
PlantUML
@startuml SeoocExample_PublicApi
namespace safety_software_seooc_example {
interface SampleLibraryAPI {
+ GetNumber()
}
}
@enduml
Bazel
architectural_design(
name = "my_arch",
public_api = ["public_api.puml"],
)
The public_api attribute also generates traceability items that can be referenced by safety_analysis targets (see Safety Analysis) via the arch_design attribute.
Internal API
The internal API view documents interfaces exposed between components inside the SEooC that are not part of the public boundary — for example, a service one component provides to a sibling component. These diagrams are parsed like static/dynamic views, but their FlatBuffers output is tracked separately via ArchitecturalDesignInfo.internal_api for downstream validation. Unlike public_api, they do not generate failure-mode traceability items.
PlantUML
Model the interface inside the namespace of the owning component so its fully-qualified name reflects the containment hierarchy:
@startuml SeoocExample_InternalApi
namespace safety_software_seooc_example {
namespace component_example {
interface InternalInterface <<interface>>{
{abstract} GetData()
}
}
}
@enduml
Bazel
architectural_design(
name = "my_arch",
internal_api = ["internal_api.puml"],
)
RST and Markdown Wrappers
When you want to combine a diagram with text, create an RST or Markdown file that embeds the diagram using the .. uml:: directive (RST) or the MyST equivalent.
RST wrapper example:
Static Architecture
-------------------
The following diagram shows the component structure of MySeooc.
.. uml:: MySeooc_StaticDesign.puml
Include both the wrapper file and the referenced .puml file in the same Bazel list — the build needs both:
architectural_design(
name = "my_arch",
static = [
"static_design.rst", # wrapper with prose
"MySeooc_StaticDesign.puml", # diagram referenced by the wrapper
],
)
Rule Reference: architectural_design
For the complete architectural_design attribute reference, see architectural_design in the rule index.