Dieser Post ist Teil 2 meiner Serie „Source Management in SFDX“. Im ersten Teil habe ich die Anforderungen an eine Source Management Strategie analysiert und das 1:1:1-Setup vorgestellt. In diesem Teil gehe ich auf die Ordnerstruktur des 1:1:1-Setup für SFDX ein und zeige, wie Teams jeder Größe damit arbeiten können.

Zusammenfassung

Ich habe auf GitHub ein kleines Repository mit Template für das 1:1:1 Setup vorbereitet, welches jederzeit gecloned und verwendet werden kann. Die Ordnerstruktur orientiert sich zwar am sfdx-isv/falcon-template, ist aber speziell darauf optimiert, für mehrere Packages als Teil einer komplexen Org eingesetzt zu werden. Das 1:1:1 Setup unterstützt weiterhin Package LCM Prozesse (von der Idee und Konzeptionierung eines oder mehrerer Packages, über Development bis zum Deployment auf Production) und Package Version LCM Prozesse (Scoping einer Package Version, Development, Testing und Deployment).

Ordnerstruktur

Jedes Repository (und folglich jedes Package) soll autark entwickelt werden können. Dafür wird in dem Template für nicht kritischen Inhalt bewusst Duplizierung in Kauf genommen. Konkret bedeutet das, dass jedes Package alle Config Files (.forceignore, .gitignore, scratch-org-def.json), Test Daten und Import Plans sowie Scripte für die Automation (Scratch Org Setup, Build & Deployment Scripte) mitbringt. In der Praxis ist der Mehraufwand für die Wartung minimal, aber der Gewinn durch Wegfall von Abhängigkeiten eines „Mutter Repositories“ immens.

Die Struktur des gesamten SFDX Projects ist sehr nah am Salesforce Standard und dem sfdx-isv/falcon-template. Ich folge damit im Grunde dem principle of least surprise und versuche, so wenig wie Möglichkeit von etablierten Standards abzuweichen. Die Ordnerstruktur für ein SFDX Projekt sieht damit im Grunde wie folgt aus:

config
data
  |-- plans
force-app
scripts
  |-- apex
  |-- build
  |-- lib
  |-- setup
src
  |-- deploy
  |-- packaged
    |-- main
      |-- default
        |-- classes
        |-- objects
        |-- labels
      |-- utils
    |-- feature-one
      |-- classes
      |-- lwc
      |-- pages
    |-- feature-two
    |-- feature-n
  |-- unpackaged

Source Ordner

Das 1:1:1 Setup weicht nur unwesentlich von bekannten Konzepten ab: force-app wird als „default“-Location für alle Metadaten verwendet, die über force:source:pull frisch bezogen wurden und dient lediglich als Zwischenstation, bis die Metadaten endgültig sortiert wurden. Der Name des eigentlichen Source Ordners ist nicht sfdx-source sondern schlicht src. Aus Auto-Vervollständigungs-Gründen der meisten Konsolen („sfdx“ + Tab führt in der Powershell z.B. zu sfdx-project.json und nicht zu meinem Ordner sfdx-source).

Während force-app nur temporären Inhalt hat, beinhaltet der Ordner src alle final sortierten Metadaten im Source Format, organisiert in den Unterordnern deploy, packaged und unpackaged.

  • deploy – Metadaten, die nicht über force:source:push|pull mit der Scratch Org synchronisiert werden sollen, aus anderen Gründen nicht packaged sind (z.B. Translations) und sich abhängig von der Zielumgebung unterscheiden können (Queues, Classic Email Templates, Workflow Rules mit organisationsweiten Email-Adressen, etc).
  • packaged – Der vollständige Content des Packages. Wird mit der Scratch Org synchronisiert.
    • main – Core Source der App, also mind. Datenmodell, Utility Classes, usw
      • default – Standard Ordner für alle Kern Metadaten
      • utils – Wie der Name schon sagt: Utilities, Test Fixtures, usw.
    • feature-one: Ein App Feature, das auf main aufbaut. Feature-Ordner machen nur Sinn, wenn das Package auch ohne Sie gebaut werden könnte (die Abhängigkeit ist also streng linear). Dadurch können einzelne Features, wenn sie zu komplex werden, schnell in eigene Packages ausgelagert werden.
  • unpackaged: Für dieses Projekt lokale Metadaten wie Layouts von Standardobjekten. Der Source ist nicht Teil des Packages, wird jedoch mit der Scratch Org synchronisiert.

Das sfdx-project.json könnte so oder so ähnlich aussehen:

{
    "packageDirectories": [
        {
            "path": "force-app",
            "default": true
        },
        {
            "path": "src/unpackaged",
            "default": false
        },
        {
            "path": "src/packaged",
            "package": "Package Name",
            "definitionFile": "config/default-scratch-def.json",
            "versionName": "Package Version Name",
            "versionNumber": "0.1.0.NEXT",
            "default": false,
            "dependencies": [
                {
                    "package": "First Dependency",
                    "versionNumber": "1.0.0.LATEST"
                },
                {
                    "package": "Second Dependency",
                    "versionNumber": "1.0.0.LATEST"
                }
            ]
        }
    ],
    "namespace": "",
    "sfdcLoginUrl": "https://login.salesforce.com",
    "sourceApiVersion": "48.0",
    "packageAliases": {
        "First Dependency": "0HoXXXXXXXXXXXX",
        "Second Dependency": "0HoXXXXXXXXXXXX",
        "Package Name": "0HoXXXXXXXXXXXX"
    }
}

Data Order

Der data Ordner beinhaltet schlicht alle Testdaten und die zugehörigen Import Pläne. Wichtig ist nur, dass wirklich alle Testdaten, die für einen vollständigen Setup der Scratch Org notwendig sind, hinzugefügt werden. Benötigt man auch Testdaten aus Abhängigkeiten, müssen diese ebenfalls hier organisiert werden. Da die Abhängigkeiten auf eine Version fixiert sind macht es Sinn, dass die Testdaten ebenfalls fixiert sind. Ändert sich die Struktur der Testdaten, und ist dies für unser Package relevant, bedeutet das so oder so ein Update der Abhängigkeit.

Script Ordner

Dieser Ordner dient der Organisation von Automatisierungs-Scripten. Je nach Ausbau der CI/CD Pipeline können das auch Build und Deployment Scripte sein. In der Regel sind es mindestens Setup Scripte für Scratch Orgs und Apex Code für z.B. den Reset von Testdaten oder das Anlegen komplexer Testdaten, die nicht über sfdx force:data:tree:import abgebildet werden können (Preisbuch Einträge, Quote/Order/Opportunity Line Items, usw)

Prozesse

Ineinander greifendes Package Lifecycle Management und Package Version Lifecycle Management.
Package Lifecycle Management und Package Version Lifecycle Management

Die Entwicklung von 2nd gen Packages hat im Grunde zwei Lifecycles: Den Package Lifecycle und den Package Version Lifecycle. Der Package Lifecycle ist organisationsweit und bezieht sich auf Setup, Management und Retirement von Packages als Ganzes. Der Package Version Lifecycle bezieht sich auf die Definition und Umsetzung einer neuen Package Version.

Die Ordnerstruktur des 1:1:1 Setup ist darauf ausgelegt, beide Lifecycle Management Prozesse optimal zu unterstützen: Die gesamte Struktur ist leicht für neue Packages duplizierbar und Automatisierung kann mit wenig Aufwand auf neue Packages umgestellt werden. Durch diszipliniertes Source Management können zu komplex gewordene Packages leicht dekomponiert werden. So können einzelne, ehemalige „Features“ eines Packages als eigenständiges Package ausgegliedert werden und autark entwickelt werden.

Package Lifecycle Management (Package LCM)

Das Package Lifecycle Management beschreibt alle Aktivitäten rund um den Setup neuer Packages, die Entwicklung dieser, den natürlichen Refinement Prozess und schlussendlich auch die Ablöse eines Packages (z.B. weil die Funktionalität nicht mehr benötigt wird).

Package Setup

Das Aufsetzen bzw. Definieren neuer Packages ist durch die „Ein Project pro Repository“ Regel denkbar einfach: Das Template Repo wird gecloned, mit force:package:create wird ein neues Package auf dem DevHub erstellt und die Setup Scripte werden geringfügig für die neue Package Id angepasst.

Package Refinement

Ein bisschen komplexer ist der Refinement Prozess eines Packages: Soll ein Feature aufgrund gewachsener Komplexität als eigenständiges Package weiterentwickelt werden, muss der Source erst aus dem originalen Package entfernt werden und anschließend in einem neuen Repository als eigenes Package eingefügt werden. Disziplin im Source Management hilft hier enorm – wenn der Source vollständig und ohne zirkuläre Abhängigkeiten in einem feature-Ordnerstruktur ist, kann man ihn mit wenig Aufwand in ein komplett neues Repository verschieben.

Zu beginn clonen wir das 1-1-1-setup-template direkt von GitHub. Anschließend können wir das geclonte Repo direkt als Submodule in unser „Master Project“ integrieren (falls wir submodules verwenden):

git submodule add https://github.com/yourusername/submodule-name.git packages/submodule-name

Nachdem wir den Source code aus dem alten Repo entfernt haben, erzeugen wir eine neue Package Version des originalen Packages (jetzt ohne das Feature). Verwendet man Semantic Versioning, ist dies der Zeitpunkt für eine neuer Major Version.

sfdx force:package:version:create -p $oldPackage -v $devHubUsername -w 30 -k $installationKey

Dann erstellen wir für das ausgegliederte Feature das neues Package im neuen Repository. Gegebenenfalls müssen wir in der sfdx-project.json noch weitere Anpassungen vornehmen (Package Version, Config Files, Abhängigkeiten, usw).

sfdx force:package:create -n "Mein Neues Package" -r src/packaged -v $devHubUsername -t Unlocked
sfdx force:package:version:create -p "Mein Neues Package" -v $devHubUsername -w 30 -k $installationKey

Auf dem Zielsystem installieren wir die neue Package Version des alten Packages mit DeprecateOnly-Flag. Dadurch wird die aus dem Package entfernte Funktionalität nicht von der Org entfernt.

sfdx force:package:install -p $oldPackage -t DeprecateOnly -u $targetOrg -k $installationKey -w 10

Jetzt müssen wir nur noch die Package Version des neuen Packages installieren, um die Funktionalität, die im vorherigen Schritt „deprecated“ wurde, wieder in Packaging zu nehmen.

sfdx force:package:install -p $newPackage -u $targetOrg -k $installationKey -w 10

Manchmal lässt sich die neue Package Version des alten Packages (d.h. zum Entfernen/Deprecaten des Features) nicht ohne Weiteres installieren. Das kann z.B. vorkommen, wenn man Apex Klassen aus dem Package entfernt hat, aber beim Migrieren des Features auch gleichzeitig einzelne Methoden aus den Klassen entfernt hat. Meistens lässt sich das ohne Probleme lösen, indem man den kompletten Source des neuen Packages einmal mit force:source:deploy „drüberbügelt“ und dann regulär das Package installiert.

sfdx force:source:deploy -p .\src\packaged\ -u $targetOrg -w 10 -l RunLocalTests

Package Version Lifecycle Management (Package Version LCM)

Der Overhead des Package Version LCM für Entwickler ist minimal: Das Setup unterstützt alle klassischen Branching/Merging Strategien mit version und feature Branches.

Setup einer neuen Package Version

Auschecken eines neuen Version Branches …

git checkout -b version/1.2.3

… und anpassen von sfdx-project.json und package.json für die neue Version.

Development an einer Package Version

Optional ausschecken eines Feature Branches (vom Version Branch), je nachdem wie groß das Team ist. Durch den JIRA-Key im Namen des Branches wird dieser auch dem entsprechenden Issue zugeordnet.

git checkout -b feature/JIRA-KEY

Im normalen Entwicklungsprozesses sollten Commits ebenfalls mit dem JIRA-Key „getaggt“ werden. Wird mit Feature Branches gearbeitet, kann nach Abschluss des Features der entsprechende Branch in den Version Branch gemerged werden (alle noch offenen Feature Branches sollten dann rebasen). Package Versions können klassisch vom Entwickler per Hand gebaut und auf Staging installiert werden oder in einer CI Pipeline auf den Pull-Request automatisiert gebaut werden.