Skip to content

Change the model from code

The model you load with LikeC4.fromWorkspace is read-only, but you can change it from code in two ways:

You can load an existing LikeC4 workspace, hand the parsed model to the Model Builder, add elements, relationships and views programmatically, and either keep working with the resulting model in memory or write DSL source back to disk.

Typical use cases:

  • Overlay infrastructure pulled from Terraform state, AWS or Kubernetes onto a hand-written model.
  • Merge multiple teams’ models and add cross-team relationships.
  • Generate views by convention (one view per team, per environment, etc.).
  • Patch a model from an external service catalogue and re-emit .c4 source files.

likec4.parsedModel(project?) returns the parsed-stage model (elements, relations, views, deployments, globals and imports) before view computation:

import { LikeC4 } from "likec4"
const likec4 = await LikeC4.fromWorkspace('./')
const parsed = await likec4.parsedModel()
// element/relation lookups work the same as on computed models
console.log(parsed.element('cloud.frontend').kind)
console.log(Object.keys(parsed.$data.views))

LikeC4.toBuilder() — get a Builder seeded from disk

Section titled “LikeC4.toBuilder() — get a Builder seeded from disk”

likec4.toBuilder(mode?, project?) returns a Model Builder pre-populated with the loaded workspace. You can then chain .model(...), .deployment(...) and .views(...) to add new content, and call .toLikeC4Model() (or .build()) to get the enriched model.

The builder is editable by default: re-declaring an element that already exists (same FQN, same kind) edits it in place rather than throwing, which is what you usually want when patching a loaded model. Pass 'strict' to get the strict builder where duplicate FQNs always throw:

const editable = await likec4.toBuilder() // editable (default)
const strict = await likec4.toBuilder('strict') // strict
import { LikeC4 } from "likec4"
const likec4 = await LikeC4.fromWorkspace('./')
const enriched = (await likec4.toBuilder())
.model(({ system, component }, _) =>
_(
system('monitoring').with(
component('grafana'),
component('prometheus'),
),
)
)
.views(({ view, $include }, _) =>
_(
view('monitoring', 'Monitoring').with(
$include('monitoring.*'),
),
)
)
.toLikeC4Model()

The same seeding entry is also available directly on the Builder, for when you already have the parsed data in hand:

import { Builder } from "likec4/model/builder"
const parsed = await likec4.parsedModel()
const builder = Builder.fromParsed(parsed.$data) // editable (default)
const strict = Builder.fromParsed(parsed.$data, 'strict') // strict

Editable vs strict — editing seeded elements

Section titled “Editable vs strict — editing seeded elements”

toBuilder() is editable by default, so you can edit seeded entries (for example, to update a title or add new children) just by re-declaring them:

import { LikeC4 } from "likec4"
const likec4 = await LikeC4.fromWorkspace('./')
const updated = (await likec4.toBuilder())
.model(({ component }, _) =>
_(
component('cloud.api', { title: 'Updated' }),
component('cloud.ui').with(
component('react'),
),
)
)
.toLikeC4Model()

In editable mode, same-FQN + same-kind redeclaration replaces the element’s properties; its children stay. Redeclaring with a different kind still throws (it’s almost certainly a programmer error). If you want re-declaring any existing FQN to throw, use the strict builder via await likec4.toBuilder('strict') (or Builder.fromParsed(data, 'strict')).

A common task is adding a new element and connecting it to something already in the DSL. Existing FQNs aren’t in the static type set, but with toBuilder() you can reference them as plain strings: the relationship is validated against the seeded model when the builder runs:

import { LikeC4 } from "likec4"
const likec4 = await LikeC4.fromWorkspace('./')
const enriched = (await likec4.toBuilder())
.model(({ system, relTo }, _) =>
_(
system('monitoring').with(
// `cloud.backend` is an existing element loaded from the DSL
relTo('cloud.backend', 'observes'),
),
)
)
.toLikeC4Model()
// or, equivalently, from the top level via `rel`:
// .model(({ system, rel }, _) =>
// _(
// system('monitoring'),
// rel('monitoring', 'cloud.backend', 'observes'),
// )
// )

Referencing an FQN that doesn’t exist throws, so typos surface immediately even though the compiler can’t catch them.

To discover what’s already in the model, walk the LikeC4Model returned by computedModel() (or inspect the parsed shape via parsedModel()):

const model = await likec4.computedModel()
for (const element of model.elements()) {
console.log(element.id, element.kind, element.title)
}

Because editing replaces rather than merges (see the caution above), patching a single property means reading the existing element and passing back the values you want to keep alongside your change:

const existing = (await likec4.parsedModel()).element('customer')
const updated = (await likec4.toBuilder())
.model(({ actor }, _) =>
_(
actor('customer', {
title: existing.title, // keep what you are not changing
metadata: { team: 'platform' }, // add / patch
}),
)
)
.toLikeC4Model()

Builder.specification() — add new kinds and tags

Section titled “Builder.specification() — add new kinds and tags”

The instance method .specification(spec) extends an existing builder with extra element / deployment / relationship kinds, tags and metadata keys. Existing keys with the same name are overridden; new keys are added. Useful when the workspace’s specification is a starting point and you want to introduce overlay-specific kinds:

import { LikeC4 } from "likec4"
const likec4 = await LikeC4.fromWorkspace('./')
const model = (await likec4.toBuilder())
.specification({
elements: {
database: { style: { shape: 'storage' } },
},
tags: {
experimental: { color: '#f00' },
},
})
.model(({ database }, _) =>
_(database('warehouse', { tags: ['experimental'] })),
)
.toLikeC4Model()

The returned builder’s types are the union of the prior types and the newly-declared spec, so element-kind helpers, tags and metadata keys accumulate across chained .specification(...) calls instead of being narrowed.

LikeC4.toTypedBuilder() — get a typed Builder

Section titled “LikeC4.toTypedBuilder() — get a typed Builder”

When the parsed model comes from a workspace, element kinds and FQNs are only known at runtime, so the builder returned by LikeC4.toBuilder() has loose types (Builder<AnyTypes>). If your code authors the model with the same specification as the DSL, pass that spec to likec4.toTypedBuilder({ specification, mode?, project? }) and get a builder typed by it, with autocomplete on element/deployment/relationship kinds, tags and metadata keys:

const builder = await likec4.toTypedBuilder({
specification: {
elements: ['actor', 'system', 'component'],
tags: ['external'],
},
})
// `system` / `component` are statically known — no cast needed.
builder.model(({ system, component }, _) =>
_(system('monitoring').with(component('grafana'))),
)

The given specification is validated against the loaded model (subset semantics): every kind / tag / metadata key you declare must exist in the loaded model, otherwise the call throws, so the types can’t silently drift away from the DSL. The loaded model may contain extra kinds you didn’t declare; you just won’t get typed helpers for them. mode defaults to 'editable', same as toBuilder.

LikeC4.toDSL() and writeDSL() — render DSL back

Section titled “LikeC4.toDSL() and writeDSL() — render DSL back”

likec4.toDSL(project?) renders the parsed model of the workspace back to LikeC4 DSL source. To write that source to disk, use the Node-only writeDSL(likec4, targetDir, options?) helper exported from likec4. It writes a single file into targetDir (the directory must exist) and returns the path of the file:

import { LikeC4, writeDSL } from "likec4"
const likec4 = await LikeC4.fromWorkspace('./')
// Render to string
const dsl = await likec4.toDSL()
// Or write to a file (default: ${targetDir}/model.c4)
const outPath = await writeDSL(likec4, './generated')
console.log(`Wrote DSL to ${outPath}`)
// Custom filename, and the project in a multi-project workspace
await writeDSL(likec4, './generated', { fileName: 'snapshot.c4', project: 'cloud' })

toDSL and writeDSL print the model loaded from the sources, not the changes you made in a builder. To print an enriched model, pass the data of the builder to generate from likec4/generators/likec4 (this is what toDSL calls) and write the result yourself:

import { writeFile } from "node:fs/promises"
import { LikeC4 } from "likec4"
import { generate } from "likec4/generators/likec4"
const likec4 = await LikeC4.fromWorkspace('./')
const enriched = (await likec4.toBuilder())
.specification({ tags: { autogenerated: { color: '#999' } } })
.model(({ component }, _) =>
_(component('cloud.new-service', { tags: ['autogenerated'] })),
)
const dsl = generate(enriched.build())
await writeFile('./generated/model.c4', dsl)

See Generate LikeC4 DSL for the input of generate, what it does not print, and the building blocks to print parts of the DSL yourself.

A model diff describes a planned change of the model in the DSL. These APIs apply or discard diffs and remove elements safely, and they change the source files of the workspace. The same operations are available in the CLI: likec4 diff and likec4 safe-delete.

API Description
likec4.modelDiffs.list(projectId?) Diffs with the number of added, removed and modified elements and relationships, and the views they change
likec4.removalImpact.analyze(targets, projectId?) Safe delete dry run: what removing the elements takes with it. null if none of the elements exists
likec4.modelEdits.preview(request, projectId?) The text of each changed file, before and after, without writing anything
likec4.modelEdits.apply(request, options?) Writes the changes. Options: projectId, and token from the preview
likec4.modelEdits.rollback(editId?) Restores the files to the state before an applied edit (the latest one by default)
import { LikeC4 } from "likec4"
import type { DiffId } from "likec4/model"
await using likec4 = await LikeC4.fromWorkspace('/path/to/workspace')
// Diffs with their changes and the views they change
const diffs = await likec4.modelDiffs.list()
// What removing an element takes with it (safe delete dry run)
const report = await likec4.removalImpact.analyze(['shop.legacyPayments'])
// Preview, apply and roll back an edit of the sources
const request = { type: 'apply-diff', diffId: 'rfc1' as DiffId, action: 'apply' } as const
const preview = await likec4.modelEdits.preview(request) // text of each changed file, before and after
if ('error' in preview) throw new Error(preview.error)
// With the token, apply refuses if the model changed after the preview
const result = await likec4.modelEdits.apply(request, { token: preview.token })
if (result.edit) await likec4.modelEdits.rollback(result.edit.id)

An edit request is one of:

  • { type: 'apply-diff', diffId, action: 'apply' | 'discard', acceptOverlaps? } applies or discards a diff. A diff with errors is not applied. A diff that overlaps other diffs is applied only with acceptOverlaps: true; the preview lists the overlaps.
  • { type: 'safe-delete', targets, options? } removes the elements and everything that references them. The options includeOrphans, includeScopedViews and includeDynamicSteps also remove elements left without relationships, views scoped to removed elements, and dynamic view steps with removed elements.

apply returns { applied, remaining, error?, edit? }. remaining lists errors left in the changed files and new errors in other files (for example, in other diffs). edit identifies the applied edit for rollback; the history is kept in memory, for the last 20 edits. Rollback fails if a file changed after the edit.

If a file cannot be written, apply restores the files it already wrote and returns applied: false with the reason. If a file cannot be restored either, the reason names it. A workspace from fromSource has no files: preview works, apply returns applied: false.