Skip to content

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's primaryOutputs array wholesale when present; otherwise the L3 repo's own committed .template.config/template.json primaryOutputs is used as a fallback if it exists.
  • defaults — overrides the composed symbols' defaultValue in place (does not add new symbols). This is how a repo pins a coherent default combination for its own host profile — e.g. Worker pins MessagingModelType=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 own build/DisableMicrosoftExtensionsStackForMinimalHost*.props profile — a missing or wrong defaults entry here is a common cause of CPM NU1010 (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.json if that repo's extend file doesn't set them via identityOverrides directly (Simple/Identity profile pulls these from the L3 template.json rather than the extend file).
  • ArtifactKind / IsMaterializedSolution / IsTemplateArtifact / UseIdentityPlatform symbol blocks — if the L3 repo's own .template.config/template.json overrides these symbol definitions, that override is preserved through composition (Get-ConnectSoftJsonObjectBlock / Set-ConnectSoftJsonObjectBlock).

Optional sibling block:

"composeOptions": {
  "profile": "Simple",
  "disableDapper": true
}

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 in apigatewayOverrides (not pulled from a separate L3 template.json).
  • gateway-only-symbols.json — a sibling file under template/ merged into templateObj.symbols unconditionally.
  • 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 for template kind but not solution kind — 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:

  1. 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's Layer3Full branch) get this for free. CI pipelines that recompose inside an already-packed, extracted nupkg must also copy this _template.config sibling into the extraction root themselves, alongside the existing template.json/ide.host.json/dotnetcli.host.json copies.
  2. The composed sources[] (for Simple/Identity and ApiGateway profiles) carries, on the Layer 3 root source:
    {
      "source": "./",
      "copyOnly": ["_template.config/**"],
      "rename": { "_template.config": ".template.config" },
      "modifiers": [
        { "…": "existing modifiers" },
        { "condition": "(IsMaterializedSolution)", "exclude": ["_template.config/**"] }
      ]
    }
    
    copyOnly skips token substitution on those files (they're foreign JSON, not host-specific content). rename is a source-level sibling property (not nested under modifiers, not a top-level template.json key — easy to miss) that the dotnet-new engine applies post-copy: any file whose path starts with _template.config is renamed to start with .template.config in the generated output. The (IsMaterializedSolution) exclude keeps the raw _template.config/ folder out of clean solution output entirely — for solution kind, _template.config is 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
Hold "Alt" / "Option" to enable pan & zoom

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/apigatewayOverrides plus defaults for symbol default pinning.
  • ✅ Use identityOverrides.defaults (not a forked appsettings/props edit) to pin a repo's coherent default symbol combination.
  • ✅ Regenerate ConnectSoft.TemplateConsumerSymbols.props and build/MaterializedDirectory.Packages.props from the submodule whenever the base-template pointer bumps — check for a repo-specific override comment first (see the AuthorizationServer UseRestApi override 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/postActionsAdds block expecting it to be merged — those keys are not read by any compose profile. Use identityOverrides.defaults for 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.json expecting 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 from template kind too.