Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Testing
  2. Extensible architecture graph

Extensible architecture graph#

The Craft architecture graph is extensible by TypeScript backend, without making the graph engine import that backend. Use this when an application has server-side concepts that the built-in Craft graph cannot express: services, layers, message brokers, data classifications, external outputs, or another dependency-injection system.

The extension has two separate parts:

  • a type extension, which gives rules typed kind details;

  • a runtime collector, which reads the TypeScript program and contributes nodes, relations, diagnostics, and source proofs.

Importing the types never activates a collector.

1. Extend the vocabulary#

Add entries to the node and relation registries with module augmentation. The augmentation must target the graph subpath:

typescript
import type {
  DependencyGraphCollector,
  DependencyGraphEdgeRegistry,
  DependencyGraphNodeRegistry,
} from '@craft-ts/dev-tools/dependency-graph';
import { assertSensitiveOutputsProtected } from '@craft-ts/dev-tools/architecture-graph';

declare module '@craft-ts/dev-tools/dependency-graph' {
  interface DependencyGraphNodeRegistry {
    'repository-service': {
      runtime: 'repository';
      repositoryName: string;
    };
  }

  interface DependencyGraphEdgeRegistry {
    'requires-repository': {
      operation: string;
    };
  }
}

The registry determines the types of the public graph API:

typescript
const repositories = graph.nodes('repository-service');
const name: string = repositories[0]!.details!.repositoryName;

const requirements = graph.edges('requires-repository');
const operation: string = requirements[0]!.details!.operation;

Keep a new node kind for a concept with its own semantics, renderer, or architecture rules. Put incidental information in the typed details object instead of creating a kind for every local symbol.

2. Write a collector#

A collector receives the shared ts-morph project and the source files already selected by the graph's tsconfig. It returns a contribution; it does not call architecture rules or mutate a renderer.

typescript
const repositoryCollector: DependencyGraphCollector = {
  name: 'repository-backend',

  collect({ rootDir, sourceFiles }) {
    const nodes = [];
    const edges = [];

    for (const sourceFile of sourceFiles) {
      // Inspect declarations and calls with ts-morph here.
      // Add a node or relation only when the syntax/type evidence is clear.
      void rootDir;
      void sourceFile;
    }

    return { nodes, edges };
  },
};

Every contributed node needs a stable id, a kind, and a human-readable label. Every relation must point to nodes in the contribution or to nodes already in the graph. A conflicting identity is rejected during the merge.

3. Attach source proofs#

A collector should explain why a fact exists. Add a proof to relations when possible:

typescript
edges.push({
  from: handlerId,
  to: repositoryId,
  kind: 'requires-repository',
  evidence: 'ast',
  details: { operation: 'list' },
  proof: {
    filePath: sourceFile.getFilePath(),
    line: call.getStartLineNumber(),
    symbol: 'listUsers',
    pattern: 'repository.list()',
  },
});

Proofs are available through graph.proofs(edge) and are included in paths:

typescript
const paths = graph.pathsBetween(handlerId, repositoryId);
for (const path of paths) {
  console.log(path.nodes.map((node) => node.label));
  console.log(path.proofs);
}

If a dependency cannot be resolved statically, emit a diagnostic or an explicit unknown relation. Do not publish an unresolved dependency as if it were proven.

4. Activate the collector explicitly#

Register the collector in the application's graph loader:

typescript
const graph = analyzeDependencyGraph({
  rootDir: workspaceRoot,
  tsConfigFilePath: 'apps/shop/tsconfig.graph.json',
  collectors: [repositoryCollector],
  middlewareCapabilities: {
    'shop.audit-sensitive-data': ['personal-data'],
  },
});

return createArchitectureGraph(graph, architectureCatalog);

This keeps runtime analysis separate from declaration merging. A type import cannot accidentally enable an expensive backend analysis or its rules.

Effect backend#

The repository currently includes an Effect adapter. It recognizes Effect Context.Service declarations and server-function requirements such as:

typescript
export class UserRepository extends Context.Service<
  UserRepository,
  UserRepositoryShape
>()('demo/UserRepository') {}

export const listUsers = serverFunction('demo.users.list', inputSchema, {
  exposure: 'client',
}).handler(({ input }) =>
  Effect.gen(function* () {
    const repository = yield* UserRepository;
    return yield* repository.list(input.filter);
  }),
);

The graph exposes effect-service, effect-operation, and effect-layer nodes, plus typed requires-service, provided-by-layer, and composes-layer relations with source proofs. Layer.succeed, Layer.sync, Layer.effect, Layer.mergeAll, and Layer.provide are followed only when their relevant symbols are statically visible. Dynamic composition is marked partial or unknown.

Sensitive data and output policies#

Effect Schema annotations can seed a conservative data-flow graph:

typescript
const Email = Schema.String.pipe(
  Schema.annotations({ sensitivity: 'personal-data' }),
);

When an annotated schema is used as the output of a client-exposed server function, the graph emits a data-classification node, an external-output node, and an exposes-data relation carrying the classification and proof. Classification is retained when propagation is uncertain; the analyser never assumes that an arbitrary transform made data safe.

Repositories declare middleware capabilities beside the graph configuration:

typescript
const graph = analyzeDependencyGraph({
  tsConfigFilePath: 'apps/shop/tsconfig.graph.json',
  middlewareCapabilities: {
    'shop.audit-sensitive-data': ['personal-data', 'secret'],
  },
});

Then enforce the policy with:

typescript
assertSensitiveOutputsProtected(graph.graph, {
  categories: ['personal-data', 'secret'],
});

The rule reports the output, classification, expected capability, and source proof when no attached server middleware provides the declared protection. Unknown protection remains blocking unless allowUnknown: true is chosen explicitly.

Rendering and JSON compatibility#

The JSON graph remains tolerant of kinds a renderer does not know. Generic renderers display the kind and label as a fallback; they do not discard the node. A backend-specific renderer or architecture rule can consume the typed details through declaration merging.

The format stays version: 1 and every addition is an optional field. Nodes carry metrics (cyclomaticOwn, cyclomaticTotal, lines, fanIn, fanOut), computed after every collector has run. A node without endLine has no complexity or line count: those fields are absent, not 0. Fan-in and fan-out count the nodes of your collector too, so a typed relation shows up in the hotspots. graphHash still reads only node ids and relations, so the metrics never move it.

Nodes may also carry doc (summary, tags, rationale) read from their declaration. The opt-in Markdown collector adds doc-page nodes and documents relations; both are part of the built-in vocabulary, so a renderer or a rule can rely on them without augmenting the registries.

When the vocabulary changes, regenerate the committed architecture catalog and run the architecture suite:

shell
npx craft-graph \
  --project apps/shop/tsconfig.graph.json \
  --root . \
  --out apps/shop/architecture/catalog \
  --format json

npx nx architecture shop

See Architecture rules for the application loader, catalog generation, and baseline rules.

←Primitive loader requirementsGraph insights→

On this page

  • 1. Extend the vocabulary
  • 2. Write a collector
  • 3. Attach source proofs
  • 4. Activate the collector explicitly
  • Effect backend
  • Sensitive data and output policies
  • Rendering and JSON compatibility

@craft-ts · MIT licensed

GitHubnpm

Search