Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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 newcomer

The 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/evidence

This 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 instances

And 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 scope

The 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 instances

welcome 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 evidence
Diagram
The runner is a compiler. welcome.yaml is the source; the typed model is the AST; platform adapters are the backend. C#, TypeScript or shell are interchangeable host languages behind the same stages.

C# 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);
}

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
Capability

Then 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.

Diagram
The micro-DSL split is also the ownership split. IT owns the shared kernel and integrates; each team owns a micro-DSL slice under its scope and publishes it. The language boundary is the org 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 lane

The 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
}

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.

Diagram
Modularity made concrete. A module (pinned by tag, commit, or branch) publishes configuration facets; a factory turns a chosen facet into a configured component; the components compose into a profile. Extend the system by publishing a module, not by editing the runner.

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.

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.

⬇ Download