Template Metadata Composition¶
This document explains how ConnectSoft templates compose .template.config/template.json (plus ide.host.json and dotnetcli.host.json) from ConnectSoft.BaseTemplate's canonical metadata and each Layer 3 repo's extend file. It reflects the actual base-template/build/Invoke-TemplateCompose.ps1 implementation (shared helpers in TemplateCompose.Common.ps1), verified against every Layer 3 repo 2026-07-17.
Important
The final .template.config/template.json is always composed, never hand-maintained in a Layer 3 repo. A repo's own committed .template.config/ is a static "last known good" snapshot for local dotnet new install <repo-path> convenience only — the version that ships in a template package and that CI actually installs is recomposed fresh from ConnectSoft.BaseTemplate + the repo's extend file every time.
ArtifactKind
Every in-scope backend and SaaS template defines ArtifactKind (solution | template, default solution) plus computed symbols IsMaterializedSolution / IsTemplateArtifact. Composition must preserve these and apply the artifact-kind-specific source modifiers described below. See Template Artifact Model.
Base template folder structure¶
ConnectSoft.BaseTemplate/
├── .template.config/
│ ├── template.json # Canonical template.json (the composition source of truth)
│ ├── ide.host.json
│ └── dotnetcli.host.json
└── build/
├── Invoke-TemplateCompose.ps1 # The compose engine (this document)
├── TemplateCompose.Common.ps1 # Shared helpers (source splitting, JSON patching)
├── Prepare-ExtendedTemplatePack.ps1 # Staging: copies the repo, stages base-template/, calls compose (Layer3Full only)
└── Validate-TemplateArtifactModel.ps1 # CI gate: asserts artifact-kind contracts on generated output
ConnectSoft.BaseTemplate/.template.config/template.json defines the full symbol set (ArtifactKind, IsMaterializedSolution, IsTemplateArtifact, every feature flag — UseMassTransit, UseNHibernate, ServiceModelType, etc.), the full sources[] array with per-feature exclude modifiers, and the base postActions. Every Layer 3 template starts from this file and applies a much smaller extend file on top.
Extend files in Layer 3 repos¶
Each Layer 3 repo carries one extend file under template/, named for the repo (e.g. identity.template.extend.json, apigateway.template.extend.json). Extend files are intentionally small — a handful of identity fields and, for some profiles, a short list of packaging exclusions. There is no symbolOverrides / symbolAdds / postActionsAdds generic delta language, and no separate YAML "recipe" layer — the compose engine reads one extend file per repo and applies a profile-specific transform.
ConnectSoft.IdentityTemplate/
└── template/
├── identity.template.extend.json # This repo's extend file
└── connectsoft.template.json # Declarative artifact-model manifest (see Template Artifact Model)
Compose profiles¶
Invoke-TemplateCompose.ps1 -ComposeProfile <profile> selects one of four transforms:
| Profile | Repos | Behavior |
|---|---|---|
Simple |
SaaS family (Billing, Entitlements, Metering, ProductsCatalog, Tenants), HealthChecksAggregator | Invoke-StandardCompose |
Identity |
Identity, Worker, AuthorizationServer, MicrosoftBotFramework | Invoke-StandardCompose (alias of Simple — same function, same extend-file shape) |
ApiGateway |
ApiGateway | Invoke-ApiGatewayCompose |
Microservice |
Microservice, AI.SoftwareFactory.Agent | Invoke-MicroserviceCompose (Layer3Full pack profile) |
Prepare-ExtendedTemplatePack.ps1 -PackProfile <Simple|Layer3Full> controls how the repo is staged before compose runs: Simple just copies the repo and rewrites the staged base-template/Directory.Build.props; Layer3Full additionally writes ConnectSoft.TemplateConsumerSymbols.props, applies persistence-none patches, and runs compose inline during staging (Simple-profile repos compose separately — see Extended templates: full multi-layer alignment playbook for where each repo's CI pipeline invokes compose).
Simple / Identity profile — identityOverrides¶
The extend file's only required block is identityOverrides. Real example (ConnectSoft.IdentityTemplate/template/identity.template.extend.json):
{
"$schema": "http://json-schema.org/draft-04/schema#",
"identityOverrides": {
"identity": "ConnectSoft.IdentityTemplate",
"groupIdentity": "ConnectSoft.IdentityTemplate",
"name": "ConnectSoft Identity Microservice",
"shortName": "connectsoft-identity",
"description": "Identity and authentication microservice on top of ConnectSoft base (submodule). Includes ConnectSoft.IdentityTemplate domain layers and forked Application / ApplicationModel.",
"tags": {
"domain": "identity",
"connectsoft-template": "identity"
},
"classifications": ["ConnectSoft", "Microservice", "Identity", "Authentication"],
"primaryOutputs": [
{ "path": "ConnectSoft.IdentityTemplate.slnx" }
],
"defaults": {
"MessagingModelType": "MassTransit",
"MassTransitTransport": "RabbitMQ",
"MassTransitPersistence": "NHibernate",
"EnableMicrosoftExtensionsAI": "false",
"UseMicrosoftAgentFramework": "false"
}
}
}
Fields:
identity,groupIdentity,name,shortName,description,tags,classifications— replace the corresponding base fields via regex substitution (Set-ConnectSoftMetadataOverrides).primaryOutputs— replaces base'sprimaryOutputsarray wholesale when present; otherwise the L3 repo's own committed.template.config/template.jsonprimaryOutputsis used as a fallback if it exists.defaults— overrides the composed symbols'defaultValuein place (does not add new symbols). This is how a repo pins a coherent default combination for its own host profile — e.g. Worker pinsMessagingModelType=MassTransit, PersistenceModelType=None; Identity pins MassTransit/RabbitMQ/NHibernate and turns off the AI/Agent Framework stack to match its minimal-host build. Generated solutions must always default to a symbol set coherent with the repo's ownbuild/DisableMicrosoftExtensionsStackForMinimalHost*.propsprofile — a missing or wrongdefaultsentry here is a common cause of CPMNU1010(PackageReference without a matching PackageVersion) on the default-args CI gate.sourceName,defaultName,preferNameDirectory,guids— read from the L3 repo's own committed.template.config/template.jsonif that repo's extend file doesn't set them viaidentityOverridesdirectly (Simple/Identityprofile pulls these from the L3 template.json rather than the extend file).ArtifactKind/IsMaterializedSolution/IsTemplateArtifact/UseIdentityPlatformsymbol blocks — if the L3 repo's own.template.config/template.jsonoverrides these symbol definitions, that override is preserved through composition (Get-ConnectSoftJsonObjectBlock/Set-ConnectSoftJsonObjectBlock).
Optional sibling block:
disableDapper strips Dapper-only symbols from the composed output (Disable-ConnectSoftDapperTemplateSymbols) for repos that don't support that persistence choice.
ApiGateway profile — apigatewayOverrides¶
Structurally similar to identityOverrides but with a few gateway-specific additions (real example, ConnectSoft.ApiGatewayTemplate/template/apigateway.template.extend.json):
{
"$schema": "http://json-schema.org/draft-04/schema#",
"apigatewayOverrides": {
"identity": "ConnectSoft.ApiGatewayTemplate",
"groupIdentity": "ConnectSoft.ApiGatewayTemplate",
"name": "ConnectSoft API Gateway Template",
"shortName": "connectsoft-apigateway",
"defaultName": "ConnectSoft.MyApiGateway1",
"description": "A solution and projects for creating and publishing a cloud native api gateways.",
"preferNameDirectory": true,
"sourceName": "ConnectSoft.ApiGatewayTemplate",
"tags": { "language": "C#", "type": "solution" },
"classifications": ["ConnectSoft", "Microservice Architecture", "Cloud Native", "Cloud", "Web", "API Gateway", "Reverse Proxy", "Solution"],
"guids": ["8C033ABC-3DFA-4BD9-B368-95D0D0315B0B"],
"primaryOutputs": [
{ "path": "ConnectSoft.ApiGatewayTemplate.slnx" }
]
},
"layer3PackagingRootExclude": {
"exclude": [
"base-template/**",
".template.config/**",
".git/**",
"build/Apply-BaseTemplatePersistenceNonePatches.ps1",
"…"
]
},
"layer3SourceModifiers": [
{ "exclude": [".vs/**", "TestResults/**", "…"] },
{ "condition": "(!Docker)", "exclude": ["…Dockerfile", "…"] }
]
}
Additional fields beyond the Simple/Identity shape:
guids,primaryOutputs,preferNameDirectory,sourceName— set directly inapigatewayOverrides(not pulled from a separate L3 template.json).gateway-only-symbols.json— a sibling file undertemplate/merged intotemplateObj.symbolsunconditionally.layer3PackagingRootExclude.exclude— an unconditional exclude list applied to the L3 repo's own root (./) source. Anything listed here is stripped from every generated artifact kind. Only list things that must never ship at all (raw.template.config/**,.git/**, packaging scripts). Do not put content here that should ship fortemplatekind but notsolutionkind — that class of content belongs in the compose script's own(IsMaterializedSolution)-gated exclude (see the RestApi/ApiGateway 2026-07-17 fix below), not the extend file's unconditional list.layer3SourceModifiers— additional feature-conditioned exclude blocks (Docker, satellite stacks), appended after the unconditional exclude and the base's(IsMaterializedSolution)block.
Lesson learned 2026-07-17
ApiGateway's layer3PackagingRootExclude once unconditionally listed .gitmodules and template/** — both are already correctly stripped from solution-kind output by the compose script's own conditioned modifier, so the unconditional duplicate was silently stripping them from template-kind output too, breaking authoring-readiness. When adding to layer3PackagingRootExclude, check first whether the same path is already excluded conditionally by Invoke-ApiGatewayCompose's $materializedLayer3Modifier — if so, don't also list it unconditionally.
Microservice profile — wholesale source/symbol replacement¶
Microservice and Agent (Layer3Full pack profile) work differently: the L3 repo carries its own fully-authored .template.config/template.json (not just a small extend delta) with its own complete sources[], symbols, and postActions. Invoke-MicroserviceCompose takes base's template.json as the starting point for metadata fields (name/shortName/etc., via the same regex substitution as Standard compose) but then replaces sources, and merges in postActions, from the L3 repo's own file wholesale — it is not a field-by-field delta merge.
{
"$schema": "http://json-schema.org/draft-04/schema#",
"identityOverrides": { "…": "same shape as Simple/Identity" },
"symbolPatches": {
"AggregateRootName": {
"defaultValue": "MicroserviceAggregateRoot",
"replaces": "MicroserviceAggregateRoot",
"fileRename": "MicroserviceAggregateRoot"
},
"AggregateRootObjectId": { "removeFileRename": true }
}
}
symbolPatches applies targeted overrides to specific symbol properties by name (used for the generic MicroserviceAggregateRoot scaffold's file/type renaming) — a much narrower mechanism than the fictional symbolOverrides/symbolAdds blocks this document previously described.
Authoring-ready output: the _template.config rename mechanism¶
This mechanism is new as of 2026-07-17 and was previously undocumented anywhere. dotnet new never copies a template's own .template.config/ folder into generated output — it's the templating engine's reserved control directory. That means a repo generated with --artifact-kind template (intended to be itself re-installable and further extensible) would ship without .template.config/, failing the authoring-ready contract that Template Artifact Model and Validate-TemplateArtifactModel.ps1 -Mode GeneratedTemplate require.
The fix, implemented in Invoke-TemplateCompose.ps1:
- After composing, the script copies the freshly-written
.template.config/to a sibling folder named_template.config/(an unreserved name) next to it — for every compose profile, unconditionally. Callers that compose directly into a repo's own staging root (the local pilot harness,Prepare-ExtendedTemplatePack.ps1'sLayer3Fullbranch) get this for free. CI pipelines that recompose inside an already-packed, extracted nupkg must also copy this_template.configsibling into the extraction root themselves, alongside the existingtemplate.json/ide.host.json/dotnetcli.host.jsoncopies. - The composed
sources[](forSimple/IdentityandApiGatewayprofiles) carries, on the Layer 3 root source:{ "source": "./", "copyOnly": ["_template.config/**"], "rename": { "_template.config": ".template.config" }, "modifiers": [ { "…": "existing modifiers" }, { "condition": "(IsMaterializedSolution)", "exclude": ["_template.config/**"] } ] }copyOnlyskips token substitution on those files (they're foreign JSON, not host-specific content).renameis a source-level sibling property (not nested undermodifiers, not a top-leveltemplate.jsonkey — easy to miss) that the dotnet-new engine applies post-copy: any file whose path starts with_template.configis renamed to start with.template.configin the generated output. The(IsMaterializedSolution)exclude keeps the raw_template.config/folder out of clean solution output entirely — forsolutionkind,_template.configis never copied at all, so the rename never applies to it.
Result: --artifact-kind template output genuinely contains .template.config/template.json (verified against real generated content, not just a passing exit code — see [[template-kind-authoring-ready-fix]] session notes), and --artifact-kind solution output stays completely free of both .template.config/ and _template.config/.
Compose script behavior (Invoke-TemplateCompose.ps1)¶
Invoke-TemplateCompose.ps1
-BaseTemplateRoot <path to base-template/.template.config's source, i.e. base-template/>
-ExtenderTemplateRoot <path to the L3 repo's staged root>
-OutputTemplateConfigDir <path to write the composed .template.config/>
-ComposeProfile <Simple|Identity|ApiGateway|Microservice>
[-ExtendFile <path>] # optional; auto-discovered under ExtenderTemplateRoot/template/*.template.extend.json if omitted
It ships in the base-template submodule (base-template/build/Invoke-TemplateCompose.ps1) and is invoked directly from each Layer 3 azure-pipelines-template.yml — there is no per-repo pack-template.ps1/template-compose*.ps1 fork. Prepare-ExtendedTemplatePack.ps1 (same submodule folder) handles staging before compose runs.
Real script workflow (Standard/Identity profile)¶
flowchart TD
BASE[Load base .template.config/template.json]
EXTEND[Load L3 extend file<br/>identityOverrides]
REGEX[Regex-substitute name/shortName/description/tags/classifications]
PULLFIELDS[Pull sourceName/defaultName/guids/primaryOutputs/<br/>ArtifactKind symbol blocks from L3's own template.json, if present]
DEFAULTS[Apply identityOverrides.defaults<br/>to composed symbol defaultValues]
SPLIT[Split base sources:<br/>base-template/** copyOnly for IsTemplateArtifact,<br/>base-template/** → src/ for IsMaterializedSolution]
L3SRC["Build Layer-3 root source ('./'):<br/>copyOnly _template.config/**, rename → .template.config,<br/>exclude base-template/**, exclude _template.config/** if IsMaterializedSolution"]
WRITE[Write composed template.json / ide.host.json / dotnetcli.host.json]
PAYLOAD[Copy composed .template.config → sibling _template.config]
BASE --> REGEX
EXTEND --> REGEX
REGEX --> PULLFIELDS
PULLFIELDS --> DEFAULTS
DEFAULTS --> SPLIT
DEFAULTS --> L3SRC
SPLIT --> WRITE
L3SRC --> WRITE
WRITE --> PAYLOAD
style BASE fill:#BBDEFB
style EXTEND fill:#C8E6C9
style WRITE fill:#A5D6A7
ApiGateway and Microservice profiles follow the same overall shape (load base → apply profile-specific overrides → split base sources → build/replace the L3 source → write → stage _template.config payload) with the field-mapping differences described above.
Best practices¶
Do¶
- ✅ Keep the extend file to the minimum needed for the profile —
identityOverrides/apigatewayOverridesplusdefaultsfor symbol default pinning. - ✅ Use
identityOverrides.defaults(not a forked appsettings/props edit) to pin a repo's coherent default symbol combination. - ✅ Regenerate
ConnectSoft.TemplateConsumerSymbols.propsandbuild/MaterializedDirectory.Packages.propsfrom the submodule whenever thebase-templatepointer bumps — check for a repo-specific override comment first (see the AuthorizationServerUseRestApioverride in [[healthchecks-materialization-coupling]] session notes) before blindly overwriting. - ✅ Verify locally with
align-20260706/repo-pilot.ps1 -Repo <name>before assuming a compose change is correct — it exercises pack → compose → install → generate (both kinds) →Validate-TemplateArtifactModel.ps1→ build, end to end.
Don't¶
- ❌ Don't add a generic
symbolOverrides/symbolAdds/postActionsAddsblock expecting it to be merged — those keys are not read by any compose profile. UseidentityOverrides.defaultsfor default-value overrides,symbolPatches(Microservice profile only) for targeted symbol-property patches, or a genuinely new symbol added to the L3 repo's own.template.config/template.json(Microservice profile, which replaces sources/symbols/postActions wholesale from that file). - ❌ Don't hand-edit a Layer 3 repo's committed
.template.config/template.jsonexpecting it to be what ships — it's a local-convenience snapshot; CI always recomposes fresh. - ❌ Don't add a path to
layer3PackagingRootExclude(ApiGateway) without checking whether it's already handled by the compose script's conditioned(IsMaterializedSolution)exclude — an unconditional duplicate strips it fromtemplatekind too.
Related documents¶
- Template Artifact Model —
ArtifactKindsymbols, theconnectsoft.template.jsonmanifest, and the materialized-output include/exclude contract. - Template Layering and Reuse — the three-layer template architecture.
- Extended templates: full multi-layer alignment playbook — submodule rules, per-repo CI wiring, snapshot regeneration.
- Templates registry — per-repo alignment status.