Skip to content

[RFC] Symbol-Level PlantUML Enforcement for TypeScript #54

@btomaj

Description

@btomaj

ArchUnitTS currently offers PlantUML-based architecture checks, but current behavior is primarily file/slice-oriented. That makes it hard to enforce class- and interface-oriented PlantUML diagrams in a way that matches TypeScript source-level intent.

This RFC proposes a new PlantUML enforcement mode for ArchUnitTS that operates at TypeScript symbol level instead of file-path level.

Goal: allow unit tests to verify whether explicit TypeScript class/interface relationships satisfy a PlantUML diagram using deterministic static-analysis semantics.

Problem

Current PlantUML support is useful for simplified slice rules, but it does not map well to diagrams whose nodes are classes/interfaces and whose relationships express source-level semantics such as:

  • A ..|> B = A implements B
  • A --|> B = A extends B
  • A ..> B = A has a class-level static dependency on B
  • A --> B = A has a class-level direct runtime dependency on B
  • A::p ..> B = property p on A has a static dependency on B
  • A::p --> B = property p on A has a direct runtime dependency on B
  • A::m ..> B = property m on A has a static dependency on B
  • A::m --> B = property m on A has a direct runtime dependency on B

In AI coded projects, it is useful to test architecture against a diagram written in those terms to enforce correctness.

The main gap is that file-level dependency extraction cannot reliably answer which class or class method depends on which target class/interface or target class/interface method.

Proposal

Add a new PlantUML enforcement mode that:

  1. Parses PlantUML class/interface relationships.
  2. Extracts symbol-level relationships from AST + type resolution.
  3. Compares extracted relationships against diagram relationships.

This mode should replace existing file/slice behavior for UML class diagrams.

Design Principle

PlantUML should be interpreted here as static TypeScript architecture constraints. The checker should only enforce relationships that are explicitly visible in TypeScript source via AST and symbol resolution.

It should not infer:

  • hidden DI container wiring
  • reflection-only dependencies
  • string-key runtime lookups

It should also keep class-level, property-level, and method-level relationships distinct.

Proposed Semantics

A ..|> B

Interpretation: A explicitly implements B.

Pass when:

  • A has an implements B heritage clause

Do not pass when:

  • A is only structurally assignable to B
  • A merely references B as a type

A --|> B

Interpretation: A explicitly extends B.

Pass when:

  • A has an extends B heritage clause

Do not pass when:

  • A is assignable to B without explicit inheritance

A ..> B

Interpretation: A has a class-level static dependency on B.

Pass when A references B in class-level type position, including:

  • constructor parameter types
  • type arguments in generics

Do not pass when:

  • B appears only in property/field types
  • B appears only in method parameter types
  • B appears only in method return types
  • B appears only in local variable types
  • B appears only inside method-level generic/type expressions

A --> B

Interpretation: A has a class-level direct runtime dependency on B.

Pass when A explicitly references symbol B in class-level runtime expression position, including:

  • direct class-level runtime references owned by class declaration itself
  • constructor body expressions referencing B, e.g. new B(), B.staticMethod(), or this.b.run()

Do not pass when:

  • B appears only as decorator/helper argument value, e.g. @Generator(B)
  • B appears only inside property initializers
  • B appears only inside method bodies

Property and method dependencies should be modeled separately from class dependencies.

A::p ..> B

Interpretation: property p on class A has a property-level static dependency on B.

Pass when p references B in type position, including:

  • property/field types
  • property-level generic/type expressions
  • getter and setter types

A::p --> B

Interpretation: property p on class A has a property-level direct runtime dependency on B.

Pass when p explicitly references runtime symbol B, including:

  • field initializers
  • property initializers
  • getter and setter bodies

A::m ..> B

Interpretation: method m on class A has a method-level static dependency on B.

Pass when m references B in type position, including:

  • method parameter types
  • method return types
  • local variable types
  • method-level generic/type expressions

A::m --> B

Interpretation: method m on class A has a method-level direct runtime dependency on B.

Pass when m explicitly references runtime symbol B, including:

  • new B()
  • B.staticMethod()
  • B.staticProperty
  • passing B as runtime value

Do not pass when:

  • method only calls through an injected abstraction handle and symbol B is not directly referenced

Explicit Non-Goals

This proposal does not aim to:

  • implement full UML semantics
  • infer runtime object graphs
  • validate method signatures against diagrams
  • infer hidden dependencies from DI frameworks or reflection
  • treat structural typing as equivalent to explicit implements

Comparison Behavior

The checker should support both:

  1. Required-edge validation
    Fail if the diagram requires a relationship that code does not contain.

  2. Forbidden-edge validation
    Fail if code contains a relationship not required in the diagram.

Default: Required-edge validation only

Relation Matching

Default comparison should be strict:

  • ..|> satisfies only ..|>
  • --|> satisfies only --|>
  • ..> satisfies only ..>
  • --> satisfies only -->

If ArchUnitTS wants a relaxed compatibility mode later, that can be optional. It should not be the default.

Examples

Example 1: Realization

PlantUML:

@startuml
class UserService
interface UserRepository
UserService ..|> UserRepository
@enduml

TypeScript pass:

class UserService implements UserRepository {}

TypeScript fail:

class UserService {
  save(): void {}
}

const repo: UserRepository = new UserService();

Reason: structural assignability should not satisfy ..|>.

Example 2: Class-Level Type Dependency

PlantUML:

@startuml
class A
class B
A ..> B
@enduml

TypeScript pass:

class A {
  constructor(b: B) {}
}

TypeScript fail:

class A {
  public b!: B;
}

Reason: property dependencies are separate from class dependencies.

Example 3: Class-Level Runtime Dependency

PlantUML:

@startuml
class A
class Uses
A --> Uses
@enduml

TypeScript pass:

@Uses
class A {}

TypeScript fail:

class A {
  dep = Uses;
}

Reason: property dependencies are separate from class dependencies.

Example 4: Property-Level Type Dependency

Property-level model:

A::dep ..> B

TypeScript pass:

class A {
  dep!: B;
}

Example 5: Property-Level Runtime Dependency

Property-level model:

A::dep --> B

TypeScript pass:

class A {
  dep = B;
}

Example 6: Method-Level Type Dependency

Method-level model:

A::run ..> B

TypeScript pass:

class A {
  run(x: B): void {}
}

Example 7: Method-Level Runtime Dependency

Method-level model:

A::run --> B

TypeScript pass:

class A {
  run() {
    return new B();
  }
}

Implementation Notes

This likely requires:

  • TS compiler API symbol resolution
  • declaration-level graph extraction
  • relation extraction by syntax category
  • PlantUML node-to-symbol resolution

Existing file-level graph extraction is not sufficient by itself for this mode.

Acceptance Criteria

An implementation would be successful if it can reliably test:

  • explicit implements for ..|>
  • explicit extends for --|>
  • constructor parameter types for class-level ..>
  • type arguments in generics for ..>
  • class-level runtime symbol refs for -->
  • property/field types for property-level ..>
  • property initializers for property-level -->
  • getter/setter signatures for property-level ..>
  • getter/setter bodies for property-level -->
  • method parameter/return/local/generic refs for method-level ..>
  • direct runtime symbol refs in methods for method-level -->
  • strict failure for structural compatibility without implements
  • strict failure for missing required dependencies

Open Questions

  • Should strict failure for missing UML relations be supported?
  • Should class diagrams stay limited to class/interface owners only?
  • Should namespaces be supported as part of this change?

Request For Feedback

Main feedback requested:

  1. Is symbol-level PlantUML enforcement in scope for ArchUnitTS?
  2. Are these explicit TypeScript semantics acceptable?
  3. Should this be implemented as a new API, or a new mode on existing APIs?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions