!include is basic preprocessing. But enterprise architecture diagrams often need to pull service lists, port numbers, AWS resource IDs from external data sources. This is the field guide for Jinja / JSON / YAML dynamic generation.
Why dynamic generation
The hard-coded .puml pain:
1 2 3 4 5 6 7 8 9 10 11 12
@startuml component "user-service" as us component "order-service" as os component "payment-service" as ps component "inventory-service" as is component "notification-service" as ns
us --> os : HTTP 8080 os --> ps : HTTP 8081 os --> is : HTTP 8082 us --> ns : HTTP 8083 @enduml
The problem: adding a service means editing two places — the component list AND the connections. With 30+ services, editing the diagram becomes a nightmare.
!foreach $svc in %yaml() component $svc.name as $svc.alias !endforeach
!foreach $svc in %yaml() !foreach $dep in $svc.deps $svc.alias --> $dep : HTTP $svc.port !endforeach !endforeach @enduml
%yaml() and %json() share the same origin — both go through PlantUML’s !foreach context under the hood.
Fix 5: pre-generate .puml with Python
When PlantUML preprocessing isn’t enough (e.g. generate ER diagrams from a database schema), use a script to generate .puml first, then run plantuml to compile:
withopen("architecture.puml", "w") as f: f.write("@startuml\n\n") for svc in config["services"]: f.write(f'component "{svc["name"]}" as {svc["alias"]}\n') f.write("\n") for svc in config["services"]: for dep in svc["deps"]: f.write(f'{svc["alias"]} --> {dep} : HTTP\n') f.write("\n@enduml\n")
1 2
python gen_diagram.py plantuml architecture.puml
Pros:
Fully programmable — any Python library works (SQLAlchemy for schema, boto3 for AWS, requests for HTTP APIs)
Easy to test — the script is plain Python, pytest covers it
PlantUML output stays compact
Cons:
An extra step — CI runs Python, then plantuml
Architecture changes go data-source (YAML) → gen_diagram.py → diagram, two steps instead of PlantUML !include‘s single step
Field example: auto-generate topology from a K8s cluster
# gen_k8s_diagram.py import yaml, subprocess from kubernetes import client, config
config.load_kube_config() v1 = client.CoreV1Api()
puml_lines = ["@startuml", "left to right direction", ""]
services = {} for svc in v1.list_service_for_all_namespaces().items: name = svc.metadata.name namespace = svc.metadata.namespace label = f"{namespace}/{name}".replace("-", "_") services[name] = {"label": label, "namespace": namespace}
puml_lines.extend([ f'component "{svc.metadata.name}" as {services[svc.metadata.name]["label"]}' for svc in v1.list_service_for_all_namespaces().items ])
puml_lines.append("") endpoints = v1.list_endpoints_for_all_namespaces() for ep in endpoints.items: for subset in (ep.subsets or []): ifnot subset.ports: continue for port in subset.ports: target_name = ep.metadata.name target_label = services[target_name]["label"] for ref in (subset.addresses or []): if ref.target_ref and ref.target_ref.kind == "Pod": pod_name = ref.target_ref.name puml_lines.append(f'{target_label} --> [{pod_name}] : {port.port}')
puml_lines.append("@enduml")
withopen("k8s-topology.puml", "w") as f: f.write("\n".join(puml_lines))
note as N1 Generated at $today() Build: $build_id() end note
%date() is the current date, %substr(s, start, end) is a string slice, %str() converts to string. All built-ins are listed in PlantUML Preprocessing.
Field foot-guns
!include does not support HTTP — local files only. To include remote files, curl first or use Python pre-generation.
%json() silently swallows include failures — wrong path won’t error, the !foreach just iterates over nothing. Add a CI lint phase that validates every !include path exists.
PlantUML string concat does not auto-coerce numbers: "service-" + 1 does not become "service-1" — you need %str($i).
!foreach nested > 3 levels errors out — PlantUML’s internal stack depth limit. For deep nesting use Python pre-generation.
Chinese service names with hyphens: component "user-service" as us is fine, but component "用户服务" as user_svc — alias must be pinyin / English; PlantUML aliases do not support Chinese + hyphen mixing.
Decision tree
1 2 3 4 5 6
Need dynamic generation? ├─ No → hard-code the .puml, done ├─ 5-10 services → !procedure for functions ├─ 10-50 services → JSON/YAML + !foreach ├─ External API/K8s/DB → Python pre-generates the .puml └─ Every API endpoint generates a sequence → OpenAPI parser + Python
The more complex the scenario, the more you should treat .puml as “the output of a template” — maintain the generator code separately, don’t pile !function calls into .puml.
Title: PlantUML preprocessing deep-dive: Jinja/JSON/YAML injection and dynamic generation