The DSL Starts as Speech
A DSL does not start with syntax. It starts when a team keeps using the same words because the words name real things.
In onboarding, those words are already present:
during setup
during first clone
during update from 3.3.2.2 to 3.3.4.3
given three fresh instances
when project builds and switches successfully
then workstation is ready to welcome a newcomerduring setup
during first clone
during update from 3.3.2.2 to 3.3.4.3
given three fresh instances
when project builds and switches successfully
then workstation is ready to welcome a newcomerThe mistake is to leave those phrases in Slack and runbooks forever.
Once the phrases stabilize, they should become data before they become code. The core artifact is not setup.ps1, setup.sh, Program.cs, or setup.ts. The core artifact is a structured setup contract:
version: welcome/v1
variables:
repoHost: git.company.local
workspace: apps/customer-portal
profiles:
frontend-developer:
runnerMatrix:
instances: 3
platforms:
- windows-11
- ubuntu-24.04
boundaries:
physical:
- wifi.company
identity:
- git.product-core
- mfa.enrolled
toolchain:
git:
version: "2.51.0"
versionPolicy: exact
node:
version: "^22.20.0"
versionPolicy: semver
packages:
winget: OpenJS.NodeJS.LTS
apt: nodejs
apk: nodejs
brew: node
dotnet:
version: "10.0.100"
versionPolicy: exact
packages:
winget: Microsoft.DotNet.SDK.10
apt: dotnet-sdk-10.0
apk: dotnet10-sdk
brew: dotnet-sdk
project:
repository: "git@${repoHost}:product/customer-portal.git"
workspace: "${workspace}"
readiness:
- restore
- build
- test
- smoke
evidence:
format: json
path: .welcome/evidenceversion: welcome/v1
variables:
repoHost: git.company.local
workspace: apps/customer-portal
profiles:
frontend-developer:
runnerMatrix:
instances: 3
platforms:
- windows-11
- ubuntu-24.04
boundaries:
physical:
- wifi.company
identity:
- git.product-core
- mfa.enrolled
toolchain:
git:
version: "2.51.0"
versionPolicy: exact
node:
version: "^22.20.0"
versionPolicy: semver
packages:
winget: OpenJS.NodeJS.LTS
apt: nodejs
apk: nodejs
brew: node
dotnet:
version: "10.0.100"
versionPolicy: exact
packages:
winget: Microsoft.DotNet.SDK.10
apt: dotnet-sdk-10.0
apk: dotnet10-sdk
brew: dotnet-sdk
project:
repository: "git@${repoHost}:product/customer-portal.git"
workspace: "${workspace}"
readiness:
- restore
- build
- test
- smoke
evidence:
format: json
path: .welcome/evidenceThis is still a sketch, but it shows the missing center of the idea. The YAML is language-agnostic. It can be validated with JSON Schema, compiled into a typed model, rendered into CI jobs, executed by a C# runner, executed by a TypeScript runner, or projected into shell commands. The contract describes expected reality. Adapters make it real.
The Contract Surface
The language needs several families of concepts.
| Family | Example concepts |
|---|---|
| Contract | SetupContract, Profile, RunnerMatrix, Variable, EvidencePolicy |
| Contexts | PhysicalAccess, IdentityAccess, WorkstationBootstrap, ToolchainBaseline, ProjectReadiness, UpdateLane |
| Versions | exact versions, semver ranges, floating versions with explicit policy |
| Packages | abstract package intent mapped to winget, apt, apk, brew, or internal catalogs |
| Phases | setup, firstClone, update, firstRun, doctor |
| Probes | dns, tls, ssh, version, clone, restore, build, smoke |
| Repair | AutoFixable, ManualOwned, ObserveOnly, FixPlan, RollbackPlan |
| Evidence | versions, probe results, logs, instance ids, failure diagnostics, redaction policy |
Then it needs isolated transitions:
transition:
name: "internal-cli-update"
from: "3.3.2.2"
to: "3.3.4.3"
keepBaselineFrozen: true
run:
- setup
- first clone
- project readiness
decision:
acceptWhen:
- all probes pass
- no manual repair happened
- evidence includes three fresh instancestransition:
name: "internal-cli-update"
from: "3.3.2.2"
to: "3.3.4.3"
keepBaselineFrozen: true
run:
- setup
- first clone
- project readiness
decision:
acceptWhen:
- all probes pass
- no manual repair happened
- evidence includes three fresh instancesAnd it needs matrices for coupled versions:
updateMatrix:
name: "frontend-runtime-2026-07"
strategy: pairwise
axes:
node: ["22.20.0", "22.21.0"]
pnpm: ["11.2.2", "11.3.0"]
vscodeCsharpExtension: ["2.63.32", "2.64.0"]
run:
- setup
- firstClone
- projectReadiness
acceptWhen:
- all required combinations pass
- no manual repair happened
- evidence includes every runner profile in scopeupdateMatrix:
name: "frontend-runtime-2026-07"
strategy: pairwise
axes:
node: ["22.20.0", "22.21.0"]
pnpm: ["11.2.2", "11.3.0"]
vscodeCsharpExtension: ["2.63.32", "2.64.0"]
run:
- setup
- firstClone
- projectReadiness
acceptWhen:
- all required combinations pass
- no manual repair happened
- evidence includes every runner profile in scopeThe phrase "no manual repair happened" is important. A setup that passes only because somebody fixed the instance by hand did not pass. The language should make that visible.
A declarative surface needs an executable face. The same model projects into commands a developer can actually run:
welcome setup converge this machine to the profile baseline
welcome doctor probe readiness, change nothing, report status and evidence
welcome update run one version transition on fresh instances
welcome matrix run a declared update matrix on fresh instanceswelcome setup converge this machine to the profile baseline
welcome doctor probe readiness, change nothing, report status and evidence
welcome update run one version transition on fresh instances
welcome matrix run a declared update matrix on fresh instanceswelcome doctor is the projection described in Part IV: the read-only half of the model, the one that turns the whole DSL into something you can demand on a fresh machine. The YAML is the language; the commands are the language compiled to action.
The Runner Is a Compiler
Yes, this has to be scriptable. But scriptable does not mean "write everything in shell." The better model is compiler-shaped:
welcome.yaml
-> parse and validate
-> resolve variables
-> build typed setup model
-> plan actions for a runner profile
-> execute through platform adapters
-> run probes
-> emit readiness evidencewelcome.yaml
-> parse and validate
-> resolve variables
-> build typed setup model
-> plan actions for a runner profile
-> execute through platform adapters
-> run probes
-> emit readiness evidenceC# can do this cleanly because the runner can be ordinary typed application code:
public sealed record ToolSpec(
string Id,
string Version,
VersionPolicy VersionPolicy,
PackageMapping Packages);
public interface PackageManagerAdapter
{
bool Supports(RunnerProfile runner);
Task<CommandPlan> PlanInstallAsync(ToolSpec tool, CancellationToken ct);
}
public interface ReadinessProbe
{
Task<ProbeResult> RunAsync(RunnerContext context, CancellationToken ct);
}public sealed record ToolSpec(
string Id,
string Version,
VersionPolicy VersionPolicy,
PackageMapping Packages);
public interface PackageManagerAdapter
{
bool Supports(RunnerProfile runner);
Task<CommandPlan> PlanInstallAsync(ToolSpec tool, CancellationToken ct);
}
public interface ReadinessProbe
{
Task<ProbeResult> RunAsync(RunnerContext context, CancellationToken ct);
}The C# runner parses the YAML into records, validates the contract, chooses adapters for the current runner profile, uses ProcessStartInfo or native APIs to execute commands, and writes evidence. The same contract could also be executed by a TypeScript runner with the same stages: parse, validate, plan, execute, probe, emit. The host language is an implementation choice. The setup contract is the product.
The Shared Kernel
The onboarding DSL should not become one giant language.
The pattern from Micro-DSLs and a Shared Kernel applies: decompose around bounded contexts and keep a small shared kernel.
Possible kernel concepts:
SetupContract
Profile
SetupPhase
Probe
Runner
ToolVersion
VersionConstraint
PackageMapping
VersionTransition
UpdateMatrix
ReadinessEvidence
Boundary
HumanBoundary
PlatformAdapter
FixPlan
CapabilitySetupContract
Profile
SetupPhase
Probe
Runner
ToolVersion
VersionConstraint
PackageMapping
VersionTransition
UpdateMatrix
ReadinessEvidence
Boundary
HumanBoundary
PlatformAdapter
FixPlan
CapabilityThen each micro-DSL owns its concern:
| Micro-DSL | Owns |
|---|---|
PhysicalAccessDsl |
rooms, networks, desks, physical prerequisites |
IdentityAccessDsl |
accounts, groups, MFA, SSH keys, certificates |
PlatformDsl |
package-manager adapters, platform capabilities, OS profiles |
ToolchainDsl |
versions, semver policies, package mappings, compatibility |
ProjectReadinessDsl |
clone, restore, build, test, run, debug |
UpdateLaneDsl |
isolated transitions, matrices, candidate baselines, rollback decisions |
EvidenceDsl |
reports, logs, instance proofs, readiness summary |
That decomposition is SOLID at the language level. Each sub-language has one reason to change. The shared kernel stays small. New tools extend the model instead of rewriting it.
That decomposition also has an org-chart shape. The team scopes of the collaborative seam — the introduction's inner-source onboarding — are the organizational projection of this same micro-DSL split. IT owns the shared kernel and the integration; each team owns a micro-DSL slice under its scope and publishes it. The language boundary and the ownership boundary are the same boundary.
Modules, References, and Facets
A scope is who owns a slice. A module is the slice itself, made publishable: a versioned unit of how-to — how to install a tool, probe a boundary, or stand up a cluster — that one team owns and any profile can import.
A module is referenced, not copied, and the reference says exactly where it comes from. It is either a directory or a git source with a pin:
modules:
- ./modules/dotnet # a local directory
- git:git.company.local/onboarding/node#t:v2.3.0 # pinned tag — reproducible
- git:git.company.local/onboarding/docker#cr:9f2a1c # pinned commit — reproducible
- git:git.company.local/onboarding/k8s#b:candidate # a branch — moving, goes through the candidate lanemodules:
- ./modules/dotnet # a local directory
- git:git.company.local/onboarding/node#t:v2.3.0 # pinned tag — reproducible
- git:git.company.local/onboarding/docker#cr:9f2a1c # pinned commit — reproducible
- git:git.company.local/onboarding/k8s#b:candidate # a branch — moving, goes through the candidate laneThe pin is not decoration. #t: (tag) and #cr: (commit ref) are reproducible — a module frozen exactly like a tool version is in Part V and Part VI. #b: (branch) is a moving source, which makes it a candidate: a branch-referenced module must pass the Part VI candidate lane before a profile is allowed to pin it. Module references inherit the whole pinning discipline for free.
What a module publishes is not code to paste but configuration facets. A facet is a named fragment of configuration; a profile selects facets; and a factory turns the chosen facet into a configured component the runner uses:
// a module publishes named configuration facets
public interface ConfigurationFacet
{
string Name { get; }
}
// a factory turns a chosen facet into a configured component
public interface ComponentFactory<T>
{
T Create(ConfigurationFacet facet); // configure the factory execution -> configured instance
}// a module publishes named configuration facets
public interface ConfigurationFacet
{
string Name { get; }
}
// a factory turns a chosen facet into a configured component
public interface ComponentFactory<T>
{
T Create(ConfigurationFacet facet); // configure the factory execution -> configured instance
}The runner never news up a probe or an adapter directly. It asks a ComponentFactory<T>, configured by the facet a module published, and gets back a configured ReadinessProbe, PackageManagerAdapter, or ClusterBootstrapper. That is Open/Closed and Dependency Inversion expressed as modules: new behavior arrives as a new module publishing a new facet, and the runner depends on the facet-and-factory contract, never on the concrete component. DRY holds too — the facet is the single source of that component's configuration.
One word, two senses, kept apart on purpose: the "configured instance" a factory returns is an object the runner uses, not the project instance of Part V. The module factory produces the first; Packer and Vagrant produce the second.
What This Gives the Company
The company gets more than a faster first day.
It gets a repeatable onboarding capability:
- New developers stop discovering old setup failures.
- Platform changes become testable before adoption.
- Support gets structured diagnostics instead of screenshots.
- Managers can ask for evidence, not reassurance.
- Tooling decisions become versioned and reviewable.
- The onboarding process improves iteratively instead of accumulating folklore.
- Small teams without a platform bench get a setup contract instead of founder-led rescue.
It also gets a stronger engineering culture. Welcome Setup-as-code applies the same discipline to developer experience that the team claims to apply to production: DDD for language, CI for proof, SOLID for implementation, DRY for source of truth, and a declarative contract before host-language code.
That is the final intention of the series.
Every newcomer does setup.
Every company needs that setup to work.
So setup must be modeled, tested, versioned, and improved like software.Every newcomer does setup.
Every company needs that setup to work.
So setup must be modeled, tested, versioned, and improved like software.The series asked one thing of every company that hires developers, and it now has a name and a command. The call is "Test your onboarding." The answer is welcome doctor, run on a fresh instance, before anyone arrives.
Then the first day can be the one Part I described: the newcomer sits down to a workstation that is already proven, opens the feature the team is discussing in an editor that is already installed, and spends the first conversation on the product — coffee in hand, not a certificate error in sight. The setup already happened, as a company capability, before the coffee was poured.
Test your onboarding.